feat: 重构顺运宝规格主链路 (#88)
This commit is contained in:
+16
-133
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user