feat: 重构顺运宝规格主链路 (#88)

This commit is contained in:
chengma
2026-08-10 12:24:23 +08:00
parent 5bb1258462
commit d1165b9e17
28 changed files with 985 additions and 531 deletions
+16 -133
View File
@@ -5,9 +5,7 @@
// 那些分别在 handler/web/others.go 和 repository/syb.go。
//
// 接口契约见 docs/admin/08-顺运宝接口.md,工单见 #46。三条最容易出事的规则:
// 1. shopee_sku_id 绝不能被同步写入/覆盖——这条已经在
// repository.UpsertSybOrder 的 SQL 层面保证,本文件不需要、
// 也不允许再传一份"新的" shopee_sku_id 进去。
// 1. shopee_sku_id 是历史兼容列,同步不读写;规格身份由 product_spec/spec_key 表达。
// 2. 增量必须从"上次同步日期当天"重新拉,不是第二天,见 syncDateRange。
// 3. 中途失败不更新 last_synced_at,见 RunSybSync 的最后一步。
package service
@@ -772,21 +770,16 @@ func writeStockDetail(db *sql.DB, baseURL string, stockRow syb.StockRow, detail
Title: item.ProductTitle,
ProductSpec: item.ProductSpec,
ShopeeGoodsID: strconv.FormatInt(item.ProductID, 10),
// `[必须]` 不设置 ShopeeSKUID——顺运宝没有这个值,
// repository.UpsertSybOrder 也不会用它覆盖已有的匹配结果。
Quantity: item.ProductQty,
PriceTwdCent: roundYuanToCent(item.ProductPrice),
ImageURL: imageURLFromThumb(baseURL, item.ProductThumb),
SybData: sybData,
Quantity: item.ProductQty,
PriceTwdCent: roundYuanToCent(item.ProductPrice),
ImageURL: imageURLFromThumb(baseURL, item.ProductThumb),
SybData: sybData,
}
created, err := repository.UpsertSybOrder(tx, order)
if err != nil {
return err
}
if _, err := AutoResolveSybShopeeSKU(tx, order); err != nil {
return err
}
if created {
report.Created++
} else {
@@ -921,7 +914,6 @@ type SybOrderView struct {
Title string
ProductSpec string
ShopeeGoodsID string
ShopeeSKUID string
Quantity int
PriceText string // "NT$239.00",和人民币价格一眼分得清
ImageURL string
@@ -936,8 +928,7 @@ type SybOrderView struct {
}
const (
SybStageMissingShopee = "missing_shopee"
SybStageSKUPending = "sku_pending"
SybStageSpecMissing = "spec_missing"
SybStagePddMissing = "pdd_missing"
SybStagePddPending = "pdd_pending"
SybStagePddCollecting = "pdd_collecting"
@@ -957,8 +948,7 @@ type SybStageOption struct {
func SybStageOptions() []SybStageOption {
return []SybStageOption{
{Value: "", Text: "全部"},
{Value: SybStageMissingShopee, Text: "未找到蝦皮商品"},
{Value: SybStageSKUPending, Text: "待确认蝦皮规格"},
{Value: SybStageSpecMissing, Text: "采购数据异常:顺运宝未提供规格"},
{Value: SybStagePddMissing, Text: "未关联 PDD"},
{Value: SybStagePddPending, Text: "PDD 待采集"},
{Value: SybStagePddCollecting, Text: "PDD 采集中"},
@@ -990,12 +980,10 @@ func SybStageLabel(stage string) string {
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.Order.SpecKey == "":
return SybStageSpecMissing, "采购数据异常:顺运宝未提供规格", "缺少规格原文,不能匹配或创建采购任务。", "核对数据"
case c.PddGoodsID == "" || c.PddCollectStatus == "":
return SybStagePddMissing, "未关联 PDD", "蝦皮规格已确认,下一步关联采购商品。", "关联 PDD"
return SybStagePddMissing, "未关联 PDD", "下一步关联采购商品。", "关联 PDD"
case c.PddCollectStatus == string(model.CollectPending):
return SybStagePddPending, "PDD 待采集", "已关联 PDD 商品,尚未创建采集任务。", "创建采集"
case c.PddCollectStatus == string(model.CollectCollecting):
@@ -1030,9 +1018,8 @@ type SybListResult struct {
// ListSybOrdersView 按筛选条件分页查货运单明细列表,翻成界面文字。
//
// `[必须]` 处理阶段由顺运宝明细、蝦皮 SKU、PDD 关联和采集状态实时推导,
// 不额外保存一份容易过期的状态字段。shopee_sku_id 只表示已经确认了
// 顺运宝规格对应的蝦皮 SKU,不代表已经完成 PDD 规格映射。
// `[必须]` 处理阶段由顺运宝规格键、PDD 关联、采集状态和规格映射实时推导,
// 不额外保存一份容易过期的状态字段。
func ListSybOrdersView(db *sql.DB, keyword, stage string, page int) (*SybListResult, error) {
stage = ParseSybStage(stage)
if stage == SybStageMappingPending || stage == SybStagePurchaseReady ||
@@ -1074,7 +1061,7 @@ func ListSybOrdersView(db *sql.DB, keyword, stage string, page int) (*SybListRes
}
// listAdvancedSybStage 在 Service 解析最新 skus_json 后筛选映射相关阶段。
// 这三个阶段不能只靠 SQL 判断:同一个 option key 是否仍存在,需要走唯一的
// 这四个阶段不能只靠 SQL 判断:同一个 option key 是否仍存在,需要走唯一的
// OptionKey 规范化逻辑。当前同步量是百到千级,先保证采购判断正确;普通列表
// 和其余阶段仍在数据库分页。
func listAdvancedSybStage(db *sql.DB, keyword, stage string, page int) (*SybListResult, error) {
@@ -1118,7 +1105,7 @@ 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,
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
@@ -1149,89 +1136,6 @@ func defaultMaxPrice(context repository.SybOrderContext) string {
return fmt.Sprintf("%d.%02d", choice.PriceCent/100, choice.PriceCent%100)
}
// 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
@@ -1239,12 +1143,10 @@ type SybProcessingDetail struct {
Title string
ProductSpec string
ShopeeGoodsID string
ShopeeSKUID string
Quantity int
Stage string
StageText string
StageHelp string
Candidates []SybSKUCandidateView
ShopeeExists bool
PddGoodsID string
PddURL string
@@ -1265,8 +1167,8 @@ func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, 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,
ShopeeGoodsID: o.ShopeeGoodsID,
Quantity: o.Quantity, ShopeeExists: context.ShopeeExists,
PddGoodsID: context.PddGoodsID, PddURL: context.PddGoodsURL,
CollectStatus: context.PddCollectStatus, CollectMsg: context.PddCollectMsg,
}
@@ -1288,25 +1190,6 @@ func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, err
}
}
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
}