feat: 建立顺运宝采购处理状态 (#67)
This commit is contained in:
+232
-23
@@ -784,6 +784,9 @@ func writeStockDetail(db *sql.DB, baseURL string, stockRow syb.StockRow, detail
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := AutoResolveSybShopeeSKU(tx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
if created {
|
||||
report.Created++
|
||||
} else {
|
||||
@@ -913,18 +916,91 @@ func EnsureSybSession(db *sql.DB, client *syb.Client, username string, now time.
|
||||
// SybOrderView 是列表页一行要显示的全部内容,已经格式化成字符串,
|
||||
// 模板里不做判断和格式化,和其余四个模块的做法一致。
|
||||
type SybOrderView struct {
|
||||
SybID string
|
||||
OrderNo string
|
||||
Title string
|
||||
ProductSpec string
|
||||
ShopeeGoodsID string
|
||||
ShopeeSKUID string
|
||||
Quantity int
|
||||
PriceText string // "NT$239.00",和人民币价格一眼分得清
|
||||
ImageURL string
|
||||
MatchText string // "已匹配" / "待匹配"
|
||||
Matched bool
|
||||
UpdatedAt string
|
||||
SybID string
|
||||
OrderNo string
|
||||
Title string
|
||||
ProductSpec string
|
||||
ShopeeGoodsID string
|
||||
ShopeeSKUID string
|
||||
Quantity int
|
||||
PriceText string // "NT$239.00",和人民币价格一眼分得清
|
||||
ImageURL string
|
||||
Stage string
|
||||
StageText string
|
||||
StageHelp string
|
||||
ActionText string
|
||||
NeedsAttention bool
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
const (
|
||||
SybStageMissingShopee = "missing_shopee"
|
||||
SybStageSKUPending = "sku_pending"
|
||||
SybStagePddMissing = "pdd_missing"
|
||||
SybStagePddPending = "pdd_pending"
|
||||
SybStagePddCollecting = "pdd_collecting"
|
||||
SybStagePddFailed = "pdd_failed"
|
||||
SybStageMappingPending = "pdd_collected"
|
||||
)
|
||||
|
||||
// SybStageOption 是顺运宝处理阶段筛选项。
|
||||
type SybStageOption struct {
|
||||
Value string
|
||||
Text string
|
||||
}
|
||||
|
||||
func SybStageOptions() []SybStageOption {
|
||||
return []SybStageOption{
|
||||
{Value: "", Text: "全部"},
|
||||
{Value: SybStageMissingShopee, Text: "未找到蝦皮商品"},
|
||||
{Value: SybStageSKUPending, Text: "待确认蝦皮规格"},
|
||||
{Value: SybStagePddMissing, Text: "未关联 PDD"},
|
||||
{Value: SybStagePddPending, Text: "PDD 待采集"},
|
||||
{Value: SybStagePddCollecting, Text: "PDD 采集中"},
|
||||
{Value: SybStagePddFailed, Text: "PDD 采集失败"},
|
||||
{Value: SybStageMappingPending, Text: "规格待匹配"},
|
||||
}
|
||||
}
|
||||
|
||||
func ParseSybStage(raw string) string {
|
||||
for _, option := range SybStageOptions() {
|
||||
if raw == option.Value {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func SybStageLabel(stage string) string {
|
||||
for _, option := range SybStageOptions() {
|
||||
if option.Value == stage {
|
||||
return option.Text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sybStageFor(c repository.SybOrderContext) (stage, text, help, action string) {
|
||||
switch {
|
||||
case !c.ShopeeExists:
|
||||
return SybStageMissingShopee, "未找到蝦皮商品", "请先导入或补充该蝦皮商品。", "查看"
|
||||
case c.Order.ShopeeSKUID == "":
|
||||
return SybStageSKUPending, "待确认蝦皮规格", "请选择与顺运宝规格原文对应的蝦皮 SKU。", "确认规格"
|
||||
case c.PddGoodsID == "" || c.PddCollectStatus == "":
|
||||
return SybStagePddMissing, "未关联 PDD", "蝦皮规格已确认,下一步关联采购商品。", "关联 PDD"
|
||||
case c.PddCollectStatus == string(model.CollectPending):
|
||||
return SybStagePddPending, "PDD 待采集", "已关联 PDD 商品,尚未创建采集任务。", "创建采集"
|
||||
case c.PddCollectStatus == string(model.CollectCollecting):
|
||||
return SybStagePddCollecting, "PDD 采集中", "客户端正在采集规格和价格,请稍后刷新。", "查看"
|
||||
case c.PddCollectStatus == string(model.CollectFailed):
|
||||
help := "PDD 采集失败,可以重新创建采集任务。"
|
||||
if c.PddCollectMsg != "" {
|
||||
help += " 原因:" + c.PddCollectMsg
|
||||
}
|
||||
return SybStagePddFailed, "PDD 采集失败", help, "重新采集"
|
||||
default:
|
||||
return SybStageMappingPending, "规格待匹配", "PDD 数据已采集,下一步匹配采购规格。", "匹配规格"
|
||||
}
|
||||
}
|
||||
|
||||
// SybListResult 是列表页要的全部数据。
|
||||
@@ -933,6 +1009,7 @@ type SybListResult struct {
|
||||
Total int
|
||||
HasAny bool
|
||||
IsFiltered bool
|
||||
Stage string
|
||||
Page int
|
||||
TotalPages int
|
||||
}
|
||||
@@ -942,8 +1019,9 @@ type SybListResult struct {
|
||||
// `[必须]` 匹配状态是**算出来的**(shopee_sku_id 是否非空),
|
||||
// 不是存的字段,见工单 #46——本工单不做规格匹配,这里只负责如实
|
||||
// 显示"有没有"。
|
||||
func ListSybOrdersView(db *sql.DB, keyword string, page int) (*SybListResult, error) {
|
||||
filter := repository.SybOrderFilter{Keyword: keyword}
|
||||
func ListSybOrdersView(db *sql.DB, keyword, stage string, page int) (*SybListResult, error) {
|
||||
stage = ParseSybStage(stage)
|
||||
filter := repository.SybOrderFilter{Keyword: keyword, Stage: stage}
|
||||
total, err := repository.CountSybOrders(db, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -952,7 +1030,7 @@ func ListSybOrdersView(db *sql.DB, keyword string, page int) (*SybListResult, er
|
||||
page = ClampPage(page, totalPages)
|
||||
offset := (page - 1) * PageSize
|
||||
|
||||
rows, err := repository.ListSybOrders(db, filter, PageSize, offset)
|
||||
rows, err := repository.ListSybOrderContexts(db, filter, PageSize, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -966,11 +1044,13 @@ func ListSybOrdersView(db *sql.DB, keyword string, page int) (*SybListResult, er
|
||||
Rows: make([]SybOrderView, 0, len(rows)),
|
||||
Total: total,
|
||||
HasAny: hasAny > 0,
|
||||
IsFiltered: strings.TrimSpace(keyword) != "",
|
||||
IsFiltered: strings.TrimSpace(keyword) != "" || stage != "",
|
||||
Stage: stage,
|
||||
Page: page,
|
||||
TotalPages: totalPages,
|
||||
}
|
||||
for _, o := range rows {
|
||||
for _, context := range rows {
|
||||
o := context.Order
|
||||
v := SybOrderView{
|
||||
SybID: o.SybID,
|
||||
OrderNo: o.OrderNo,
|
||||
@@ -982,6 +1062,8 @@ func ListSybOrdersView(db *sql.DB, keyword string, page int) (*SybListResult, er
|
||||
ImageURL: o.ImageURL,
|
||||
UpdatedAt: formatLocalTime(o.UpdatedAt),
|
||||
}
|
||||
v.Stage, v.StageText, v.StageHelp, v.ActionText = sybStageFor(context)
|
||||
v.NeedsAttention = v.Stage != SybStagePddCollecting
|
||||
if o.PriceTwdCent > 0 {
|
||||
v.PriceText = fmt.Sprintf("NT$%.2f", float64(o.PriceTwdCent)/100)
|
||||
} else {
|
||||
@@ -993,17 +1075,144 @@ func ListSybOrdersView(db *sql.DB, keyword string, page int) (*SybListResult, er
|
||||
if o.ProductSpec == "" {
|
||||
v.ProductSpec = placeholder
|
||||
}
|
||||
if o.ShopeeSKUID != "" {
|
||||
v.Matched = true
|
||||
v.MatchText = "已匹配"
|
||||
} else {
|
||||
v.MatchText = "待匹配"
|
||||
}
|
||||
result.Rows = append(result.Rows, v)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AutoResolveSybShopeeSKU 只接受确定性唯一候选:先比规格原文,再比解析后的
|
||||
// 颜色和尺码。零候选或多候选都不写库,交给采购员确认。
|
||||
func AutoResolveSybShopeeSKU(q repository.Execer, order model.SybOrder) (string, error) {
|
||||
if order.ShopeeGoodsID == "" || order.ProductSpec == "" {
|
||||
return "", nil
|
||||
}
|
||||
skus, err := repository.ListShopeeSKUsByGoodsID(q, order.ShopeeGoodsID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var exact []model.ShopeeSKU
|
||||
wantRaw := strings.TrimSpace(order.ProductSpec)
|
||||
for _, sku := range skus {
|
||||
if strings.TrimSpace(sku.SpecRaw) == wantRaw {
|
||||
exact = append(exact, sku)
|
||||
}
|
||||
}
|
||||
candidate := uniqueShopeeSKU(exact)
|
||||
if candidate == nil {
|
||||
color, size, _, ok := ParseSpec(order.ProductSpec)
|
||||
if ok {
|
||||
var parsed []model.ShopeeSKU
|
||||
for _, sku := range skus {
|
||||
if sku.ParseOK && strings.TrimSpace(sku.Color) == strings.TrimSpace(color) &&
|
||||
strings.TrimSpace(sku.Size) == strings.TrimSpace(size) {
|
||||
parsed = append(parsed, sku)
|
||||
}
|
||||
}
|
||||
candidate = uniqueShopeeSKU(parsed)
|
||||
}
|
||||
}
|
||||
if candidate == nil {
|
||||
return "", nil
|
||||
}
|
||||
written, err := repository.SetSybShopeeSKUIfEmpty(q, order.SybID, candidate.SKUID)
|
||||
if err != nil || !written {
|
||||
return "", err
|
||||
}
|
||||
return candidate.SKUID, nil
|
||||
}
|
||||
|
||||
func uniqueShopeeSKU(candidates []model.ShopeeSKU) *model.ShopeeSKU {
|
||||
if len(candidates) != 1 {
|
||||
return nil
|
||||
}
|
||||
return &candidates[0]
|
||||
}
|
||||
|
||||
// AssignSybShopeeSKU 保存人工选择,并在事务内验证 SKU 确实属于该蝦皮商品。
|
||||
func AssignSybShopeeSKU(db *sql.DB, sybID, skuID string) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始确认蝦皮规格事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
context, err := repository.GetSybOrderContext(tx, strings.TrimSpace(sybID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if context == nil {
|
||||
return fmt.Errorf("顺运宝明细不存在")
|
||||
}
|
||||
sku, err := repository.GetShopeeSKUByID(tx, strings.TrimSpace(skuID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sku == nil || sku.GoodsID != context.Order.ShopeeGoodsID {
|
||||
return fmt.Errorf("所选蝦皮规格不属于该商品,请刷新后重试")
|
||||
}
|
||||
if err := repository.SetSybShopeeSKU(tx, context.Order.SybID, sku.SKUID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
type SybSKUCandidateView struct {
|
||||
SKUID string
|
||||
SpecRaw string
|
||||
Color string
|
||||
Size string
|
||||
Selected bool
|
||||
}
|
||||
|
||||
// SybProcessingDetail 是顺运宝“下一步”弹窗第一阶段需要的上下文。
|
||||
type SybProcessingDetail struct {
|
||||
SybID string
|
||||
OrderNo string
|
||||
Title string
|
||||
ProductSpec string
|
||||
ShopeeGoodsID string
|
||||
ShopeeSKUID string
|
||||
Quantity int
|
||||
Stage string
|
||||
StageText string
|
||||
StageHelp string
|
||||
Candidates []SybSKUCandidateView
|
||||
ShopeeExists bool
|
||||
}
|
||||
|
||||
func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, error) {
|
||||
context, err := repository.GetSybOrderContext(db, strings.TrimSpace(sybID))
|
||||
if err != nil || context == nil {
|
||||
return nil, err
|
||||
}
|
||||
o := context.Order
|
||||
d := &SybProcessingDetail{
|
||||
SybID: o.SybID, OrderNo: o.OrderNo, Title: o.Title, ProductSpec: o.ProductSpec,
|
||||
ShopeeGoodsID: o.ShopeeGoodsID, ShopeeSKUID: o.ShopeeSKUID,
|
||||
Quantity: o.Quantity, ShopeeExists: context.ShopeeExists,
|
||||
}
|
||||
d.Stage, d.StageText, d.StageHelp, _ = sybStageFor(*context)
|
||||
if context.ShopeeExists {
|
||||
skus, err := repository.ListShopeeSKUsByGoodsID(db, o.ShopeeGoodsID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, sku := range skus {
|
||||
color, size := sku.Color, sku.Size
|
||||
if color == "" {
|
||||
color = placeholder
|
||||
}
|
||||
if size == "" {
|
||||
size = placeholder
|
||||
}
|
||||
d.Candidates = append(d.Candidates, SybSKUCandidateView{
|
||||
SKUID: sku.SKUID, SpecRaw: sku.SpecRaw, Color: color, Size: size,
|
||||
Selected: sku.SKUID == o.ShopeeSKUID,
|
||||
})
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// SaveSybLoginSession 登录成功后把会话缓存进数据库。
|
||||
//
|
||||
// `[必须]` 缓存写失败不能让已经登录的会话失效——本函数把错误原样
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
func seedWorkflowOrder(t *testing.T, db *sql.DB, sybID, goodsID, spec string) model.SybOrder {
|
||||
t.Helper()
|
||||
order := model.SybOrder{
|
||||
SybID: sybID, OrderNo: "ORDER-" + sybID, Title: "测试商品",
|
||||
ProductSpec: spec, ShopeeGoodsID: goodsID, Quantity: 1, SybData: `{}`,
|
||||
}
|
||||
if _, err := repository.UpsertSybOrder(db, order); err != nil {
|
||||
t.Fatalf("准备顺运宝明细失败: %v", err)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
func seedWorkflowSKU(t *testing.T, db *sql.DB, skuID, goodsID, spec string) {
|
||||
t.Helper()
|
||||
color, size, advice, ok := ParseSpec(spec)
|
||||
if err := repository.UpsertShopeeSKU(db, skuID, goodsID, spec, color, size, advice, ok, ""); err != nil {
|
||||
t.Fatalf("准备蝦皮规格失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readOrderSKU(t *testing.T, db *sql.DB, sybID string) string {
|
||||
t.Helper()
|
||||
var sku sql.NullString
|
||||
if err := db.QueryRow(`SELECT shopee_sku_id FROM syb_orders WHERE syb_id = ?`, sybID).Scan(&sku); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return sku.String
|
||||
}
|
||||
|
||||
func TestAutoResolveSybShopeeSKU_只采用唯一精确候选(t *testing.T) {
|
||||
t.Run("规格原文唯一一致", func(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
if err := repository.UpsertShopeeProduct(db, "SP-1", "商品", "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedWorkflowSKU(t, db, "SKU-1", "SP-1", "黑色,M")
|
||||
order := seedWorkflowOrder(t, db, "SYB-1", "SP-1", "黑色,M")
|
||||
|
||||
got, err := AutoResolveSybShopeeSKU(db, order)
|
||||
if err != nil || got != "SKU-1" || readOrderSKU(t, db, "SYB-1") != "SKU-1" {
|
||||
t.Fatalf("应自动确认唯一原文候选: got=%q stored=%q err=%v", got, readOrderSKU(t, db, "SYB-1"), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("解析后颜色尺码唯一一致", func(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
if err := repository.UpsertShopeeProduct(db, "SP-2", "商品", "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedWorkflowSKU(t, db, "SKU-2", "SP-2", "黑色,M【建議40-50公斤】")
|
||||
order := seedWorkflowOrder(t, db, "SYB-2", "SP-2", "黑色,M")
|
||||
|
||||
got, err := AutoResolveSybShopeeSKU(db, order)
|
||||
if err != nil || got != "SKU-2" {
|
||||
t.Fatalf("应采用唯一颜色尺码候选: got=%q err=%v", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("多个颜色尺码候选不猜测", func(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
if err := repository.UpsertShopeeProduct(db, "SP-3", "商品", "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedWorkflowSKU(t, db, "SKU-3A", "SP-3", "黑色,M【建議40-50公斤】")
|
||||
seedWorkflowSKU(t, db, "SKU-3B", "SP-3", "黑色,M【建議50-60公斤】")
|
||||
order := seedWorkflowOrder(t, db, "SYB-3", "SP-3", "黑色,M")
|
||||
|
||||
got, err := AutoResolveSybShopeeSKU(db, order)
|
||||
if err != nil || got != "" || readOrderSKU(t, db, "SYB-3") != "" {
|
||||
t.Fatalf("多候选必须留给人工确认: got=%q stored=%q err=%v", got, readOrderSKU(t, db, "SYB-3"), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAssignSybShopeeSKU_拒绝其他商品的规格(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
for _, goodsID := range []string{"SP-A", "SP-B"} {
|
||||
if err := repository.UpsertShopeeProduct(db, goodsID, goodsID, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
seedWorkflowSKU(t, db, "SKU-A", "SP-A", "黑色,M")
|
||||
seedWorkflowSKU(t, db, "SKU-B", "SP-B", "白色,L")
|
||||
seedWorkflowOrder(t, db, "SYB-A", "SP-A", "未知规格")
|
||||
|
||||
if err := AssignSybShopeeSKU(db, "SYB-A", "SKU-B"); err == nil {
|
||||
t.Fatal("不得把其他蝦皮商品的 SKU 保存到当前顺运宝明细")
|
||||
}
|
||||
if got := readOrderSKU(t, db, "SYB-A"); got != "" {
|
||||
t.Fatalf("越权选择后不应写库,实际 %q", got)
|
||||
}
|
||||
if err := AssignSybShopeeSKU(db, "SYB-A", "SKU-A"); err != nil {
|
||||
t.Fatalf("同商品 SKU 应保存成功: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSybOrdersView_阶段筛选区分蝦皮识别和PDD准备(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
seedWorkflowOrder(t, db, "SYB-MISSING", "NOT-IMPORTED", "黑色,M")
|
||||
if err := repository.UpsertShopeeProduct(db, "SP-READY", "商品", "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedWorkflowSKU(t, db, "SKU-READY", "SP-READY", "黑色,M")
|
||||
seedWorkflowOrder(t, db, "SYB-SKU", "SP-READY", "无法自动匹配")
|
||||
order := seedWorkflowOrder(t, db, "SYB-PDD", "SP-READY", "黑色,M")
|
||||
if _, err := AutoResolveSybShopeeSKU(db, order); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct{ stage, wantID string }{
|
||||
{SybStageMissingShopee, "SYB-MISSING"},
|
||||
{SybStageSKUPending, "SYB-SKU"},
|
||||
{SybStagePddMissing, "SYB-PDD"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
result, err := ListSybOrdersView(db, "", tc.stage, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Total != 1 || len(result.Rows) != 1 || result.Rows[0].SybID != tc.wantID {
|
||||
t.Fatalf("阶段 %s 结果错误: %+v", tc.stage, result)
|
||||
}
|
||||
if result.Rows[0].StageText == "" || result.Rows[0].ActionText == "" {
|
||||
t.Fatalf("阶段 %s 必须有文字和下一步: %+v", tc.stage, result.Rows[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user