feat: 建立顺运宝采购处理状态 (#67)

This commit is contained in:
chengma
2026-08-09 22:32:19 +08:00
parent c01f3f8b0e
commit c6ef54dce1
12 changed files with 730 additions and 73 deletions
+232 -23
View File
@@ -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 登录成功后把会话缓存进数据库。
//
// `[必须]` 缓存写失败不能让已经登录的会话失效——本函数把错误原样