feat: 完成规格映射与采购任务创建 (#69)

This commit is contained in:
chengma
2026-08-09 22:55:14 +08:00
parent 610e9e3749
commit 4433e3af37
18 changed files with 976 additions and 135 deletions
+153 -66
View File
@@ -916,31 +916,36 @@ 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
Stage string
StageText string
StageHelp string
ActionText string
NeedsAttention 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
CanPurchase bool
DefaultMaxPrice string
UpdatedAt string
}
const (
SybStageMissingShopee = "missing_shopee"
SybStageSKUPending = "sku_pending"
SybStagePddMissing = "pdd_missing"
SybStagePddPending = "pdd_pending"
SybStagePddCollecting = "pdd_collecting"
SybStagePddFailed = "pdd_failed"
SybStageMappingPending = "pdd_collected"
SybStageMissingShopee = "missing_shopee"
SybStageSKUPending = "sku_pending"
SybStagePddMissing = "pdd_missing"
SybStagePddPending = "pdd_pending"
SybStagePddCollecting = "pdd_collecting"
SybStagePddFailed = "pdd_failed"
SybStageMappingPending = "mapping_pending"
SybStagePurchaseReady = "purchase_ready"
SybStageTaskCreated = "task_created"
SybStagePurchaseBlocked = "purchase_blocked"
)
// SybStageOption 是顺运宝处理阶段筛选项。
@@ -959,6 +964,9 @@ func SybStageOptions() []SybStageOption {
{Value: SybStagePddCollecting, Text: "PDD 采集中"},
{Value: SybStagePddFailed, Text: "PDD 采集失败"},
{Value: SybStageMappingPending, Text: "规格待匹配"},
{Value: SybStagePurchaseReady, Text: "可创建采购任务"},
{Value: SybStageTaskCreated, Text: "已创建采购任务"},
{Value: SybStagePurchaseBlocked, Text: "采购数据异常"},
}
}
@@ -998,8 +1006,14 @@ func sybStageFor(c repository.SybOrderContext) (stage, text, help, action string
help += " 原因:" + c.PddCollectMsg
}
return SybStagePddFailed, "PDD 采集失败", help, "重新采集"
default:
case c.HasActiveTask:
return SybStageTaskCreated, "已创建采购任务", "已有未结束的采购任务,请到采集采购页面查看。", "查看任务"
case !mappingIsValid(c):
return SybStageMappingPending, "规格待匹配", "PDD 数据已采集,下一步匹配采购规格。", "匹配规格"
case c.Order.Quantity <= 0:
return SybStagePurchaseBlocked, "采购数据异常", "顺运宝采购数量不是正数,不能创建采购任务。", "核对数据"
default:
return SybStagePurchaseReady, "可创建采购任务", "规格映射有效,可以勾选并创建采购任务。", "核对规格"
}
}
@@ -1021,6 +1035,10 @@ type SybListResult struct {
// 顺运宝规格对应的蝦皮 SKU,不代表已经完成 PDD 规格映射。
func ListSybOrdersView(db *sql.DB, keyword, stage string, page int) (*SybListResult, error) {
stage = ParseSybStage(stage)
if stage == SybStageMappingPending || stage == SybStagePurchaseReady ||
stage == SybStageTaskCreated || stage == SybStagePurchaseBlocked {
return listAdvancedSybStage(db, keyword, stage, page)
}
filter := repository.SybOrderFilter{Keyword: keyword, Stage: stage}
total, err := repository.CountSybOrders(db, filter)
if err != nil {
@@ -1050,36 +1068,87 @@ func ListSybOrdersView(db *sql.DB, keyword, stage string, page int) (*SybListRes
TotalPages: totalPages,
}
for _, context := range rows {
o := context.Order
v := SybOrderView{
SybID: o.SybID,
OrderNo: o.OrderNo,
Title: o.Title,
ProductSpec: o.ProductSpec,
ShopeeGoodsID: o.ShopeeGoodsID,
ShopeeSKUID: o.ShopeeSKUID,
Quantity: o.Quantity,
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 {
v.PriceText = placeholder
}
if o.Title == "" {
v.Title = placeholder
}
if o.ProductSpec == "" {
v.ProductSpec = placeholder
}
result.Rows = append(result.Rows, v)
result.Rows = append(result.Rows, sybOrderViewFor(context))
}
return result, nil
}
// listAdvancedSybStage 在 Service 解析最新 skus_json 后筛选映射相关阶段。
// 这三个阶段不能只靠 SQL 判断:同一个 option key 是否仍存在,需要走唯一的
// OptionKey 规范化逻辑。当前同步量是百到千级,先保证采购判断正确;普通列表
// 和其余阶段仍在数据库分页。
func listAdvancedSybStage(db *sql.DB, keyword, stage string, page int) (*SybListResult, error) {
contexts, err := repository.ListSybOrderContexts(db,
repository.SybOrderFilter{Keyword: keyword}, -1, 0)
if err != nil {
return nil, err
}
filtered := make([]repository.SybOrderContext, 0)
for _, context := range contexts {
value, _, _, _ := sybStageFor(context)
if value == stage {
filtered = append(filtered, context)
}
}
total := len(filtered)
totalPages := TotalPages(total)
page = ClampPage(page, totalPages)
start := (page - 1) * PageSize
if start > total {
start = total
}
end := start + PageSize
if end > total {
end = total
}
result := &SybListResult{Total: total, Stage: stage, Page: page, TotalPages: totalPages,
IsFiltered: true, Rows: make([]SybOrderView, 0, end-start)}
hasAny, err := repository.CountSybOrdersTotal(db)
if err != nil {
return nil, err
}
result.HasAny = hasAny > 0
for _, context := range filtered[start:end] {
result.Rows = append(result.Rows, sybOrderViewFor(context))
}
return result, nil
}
func sybOrderViewFor(context repository.SybOrderContext) SybOrderView {
o := context.Order
v := SybOrderView{SybID: o.SybID, OrderNo: o.OrderNo, Title: o.Title,
ProductSpec: o.ProductSpec, ShopeeGoodsID: o.ShopeeGoodsID,
ShopeeSKUID: o.ShopeeSKUID, Quantity: o.Quantity, ImageURL: o.ImageURL,
UpdatedAt: formatLocalTime(o.UpdatedAt)}
v.Stage, v.StageText, v.StageHelp, v.ActionText = sybStageFor(context)
v.CanPurchase = v.Stage == SybStagePurchaseReady
v.DefaultMaxPrice = defaultMaxPrice(context)
v.NeedsAttention = v.Stage != SybStagePddCollecting && v.Stage != SybStageTaskCreated
if o.PriceTwdCent > 0 {
v.PriceText = fmt.Sprintf("NT$%.2f", float64(o.PriceTwdCent)/100)
} else {
v.PriceText = placeholder
}
if v.Title == "" {
v.Title = placeholder
}
if v.ProductSpec == "" {
v.ProductSpec = placeholder
}
return v
}
func defaultMaxPrice(context repository.SybOrderContext) string {
if !mappingIsValid(context) {
return ""
}
choice, err := findPddChoice(context.PddSkusJSON, context.MappingOptionKey)
if err != nil || choice == nil || !choice.HasPrice {
return ""
}
return fmt.Sprintf("%d.%02d", choice.PriceCent/100, choice.PriceCent%100)
}
// AutoResolveSybShopeeSKU 只接受确定性唯一候选:先比规格原文,再比解析后的
// 颜色和尺码。零候选或多候选都不写库,交给采购员确认。
func AutoResolveSybShopeeSKU(q repository.Execer, order model.SybOrder) (string, error) {
@@ -1165,23 +1234,27 @@ type SybSKUCandidateView struct {
// 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
PddGoodsID string
PddURL string
CollectStatus string
CollectMsg string
CanCollect bool
SybID string
OrderNo string
Title string
ProductSpec string
ShopeeGoodsID string
ShopeeSKUID string
Quantity int
Stage string
StageText string
StageHelp string
Candidates []SybSKUCandidateView
ShopeeExists bool
PddGoodsID string
PddURL string
CollectStatus string
CollectMsg string
CanCollect bool
PddDimensionNames []string
PddChoices []PddOptionChoice
MappingValid bool
HasActiveTask bool
}
func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, error) {
@@ -1200,6 +1273,20 @@ func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, err
d.CanCollect = context.PddGoodsID != "" &&
(context.PddCollectStatus == string(model.CollectPending) ||
context.PddCollectStatus == string(model.CollectFailed))
d.HasActiveTask = context.HasActiveTask
if context.PddCollectStatus == string(model.CollectCollected) && context.PddSkusJSON != "" {
choices, names, parseErr := pddOptionChoices(context.PddSkusJSON)
if parseErr == nil {
d.PddDimensionNames = names
d.PddChoices = choices
for i := range d.PddChoices {
d.PddChoices[i].Selected = d.PddChoices[i].Key == context.MappingOptionKey
if d.PddChoices[i].Selected {
d.MappingValid = true
}
}
}
}
d.Stage, d.StageText, d.StageHelp, _ = sybStageFor(*context)
if context.ShopeeExists {
skus, err := repository.ListShopeeSKUsByGoodsID(db, o.ShopeeGoodsID)