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
+50 -2
View File
@@ -54,7 +54,8 @@ func sybLoginHint(ocrURL string) string {
// 是配置错了还是服务挂了,不能弹一个空的手工输入框了事。
func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, msg, loginReason string) {
pageNum := service.ParsePage(pageRaw)
result, err := service.ListSybOrdersView(h.db, keyword, pageNum)
stage := service.ParseSybStage(c.Query("stage"))
result, err := service.ListSybOrdersView(h.db, keyword, stage, pageNum)
if err != nil {
fail(c, http.StatusInternalServerError,
"读取顺运宝货运单列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
@@ -80,6 +81,17 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
if keyword != "" {
values.Set("order_no", keyword)
}
if stage != "" {
values.Set("stage", stage)
}
detailValues := url.Values{}
if keyword != "" {
detailValues.Set("order_no", keyword)
}
if stage != "" {
detailValues.Set("stage", stage)
}
detailValues.Set("page", strconv.Itoa(result.Page))
// `[必须]` 密码不读出来显示、也不回显到 HTML,见工单 #46。
// 这里只取 username(只读展示)和 base_url 是否配置正确。
@@ -139,6 +151,9 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
c.HTML(http.StatusOK, "syb/list", page(c, "syb", "顺运宝数据", gin.H{
"Keyword": keyword,
"StageFilter": stage,
"StageOptions": service.SybStageOptions(),
"CurrentPage": result.Page,
"Rows": result.Rows,
"Status": status,
"HasAny": result.HasAny,
@@ -163,9 +178,28 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
"HistoryFetchPagination": sybHistoryPartialPagination(history.Page, history.TotalPages,
keyword, dateFrom, dateTo),
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
"DetailURL": "/syb/detail?" + detailValues.Encode(),
}))
}
// SybDetail 返回一条货运单明细的采购处理弹窗片段。
func (h *Handler) SybDetail(c *gin.Context) {
detail, err := service.GetSybProcessingDetail(h.db, c.Query("id"))
if err != nil {
fail(c, http.StatusInternalServerError, "读取顺运宝处理详情失败:"+err.Error())
return
}
if detail == nil {
fail(c, http.StatusNotFound, "顺运宝货运单明细不存在。")
return
}
c.HTML(http.StatusOK, "syb/detail_modal", gin.H{
"D": detail, "CSRFToken": csrfToken(c),
"Keyword": c.Query("order_no"), "StageFilter": c.Query("stage"),
"CurrentPage": service.ParsePage(c.Query("page")),
})
}
// SybSyncHistory 只返回同步记录弹窗内部的 HTML。它只读本地数据库,不调用
// 顺运宝;完整页面和局部刷新共用同一份模板,避免字段或分页标记漂移。
func (h *Handler) SybSyncHistory(c *gin.Context) {
@@ -243,6 +277,9 @@ func sybHistoryPartialURL(pageNum int, keyword, dateFrom, dateTo string) string
// `[必须]` 显示筛选后的**全量**总数,不是本页行数,见工单 #43 定下的规则。
func sybStatusLine(result *service.SybListResult) string {
prefix := fmt.Sprintf("共 %d 条货运单明细", result.Total)
if label := service.SybStageLabel(result.Stage); label != "" {
prefix = fmt.Sprintf("%s:%d 条", label, result.Total)
}
if !result.HasAny {
return "还没有货运单明细。点上方「同步」从顺运宝拉取。"
}
@@ -510,13 +547,24 @@ func (h *Handler) sybRedirectRange(c *gin.Context, message string) {
// appendSybFormState 保存同步/登录跳转后仍需要的页面状态。
func appendSybFormState(params url.Values, c *gin.Context) {
for _, name := range []string{"order_no", "date_from", "date_to"} {
for _, name := range []string{"order_no", "stage", "page", "date_from", "date_to"} {
if value := strings.TrimSpace(c.PostForm(name)); value != "" {
params.Set(name, value)
}
}
}
// SybSelectSKU 保存采购员确认的蝦皮规格。Service 会再次验证 SKU 归属,
// 不能靠表单隐藏字段决定商品关系。
func (h *Handler) SybSelectSKU(c *gin.Context) {
err := service.AssignSybShopeeSKU(h.db, c.PostForm("syb_id"), c.PostForm("shopee_sku_id"))
if err != nil {
h.sybRedirect(c, "蝦皮规格未保存:"+err.Error())
return
}
h.sybRedirect(c, "蝦皮规格已确认,下一步按处理状态继续。")
}
// SybMatch 保存规格匹配结果。
//
// 关键:保存的是**可复用的 SKU 映射**(写 sku_mappings),
+2
View File
@@ -71,10 +71,12 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
// 3. 顺运宝数据
pages.GET("/syb", h.SybList)
pages.GET("/syb/detail", h.SybDetail) // 采购处理弹窗
pages.GET("/syb/sync-history", h.SybSyncHistory) // 弹窗局部刷新,只读本地同步记录
pages.GET("/syb/captcha", h.SybCaptcha) // 登录弹窗里的验证码图片
pages.POST("/syb/sync", h.SybSync)
pages.POST("/syb/login-and-sync", h.SybLoginAndSync)
pages.POST("/syb/select-sku", h.SybSelectSKU)
pages.POST("/syb/match", h.SybMatch)
pages.POST("/syb/create-task", h.SybCreateTask)
pages.POST("/syb/delete", h.SybDelete)
+1
View File
@@ -99,6 +99,7 @@ func TestMainPagesReturnOK(t *testing.T) {
"同步记录", "刷新同步记录", `data-syb-history-slot`,
`data-history-fetch-url=`,
`name="date_from"`, `name="date_to"`,
`name="stage"`, "处理阶段", "下一步", `data-detail-url=`,
"按顺运宝货运单创建日期(UTC+8)同步",
} {
if !strings.Contains(sybResponse.Body.String(), want) {
+27
View File
@@ -301,6 +301,33 @@ func ListShopeeSKUsByGoodsID(q Execer, goodsID string) ([]model.ShopeeSKU, error
return list, rows.Err()
}
// GetShopeeSKUByID 按规格 ID 查询一条蝦皮 SKU。查不到返回 (nil, nil)。
func GetShopeeSKUByID(q Execer, skuID string) (*model.ShopeeSKU, error) {
var sk model.ShopeeSKU
var color, size, advice, skuCode sql.NullString
var parseOK, isManual int
err := q.QueryRow(`
SELECT sku_id, goods_id, spec_raw, color, size, advice, parse_ok, sku_code,
is_manual, created_at, updated_at
FROM shopee_skus WHERE sku_id = ?`, skuID).Scan(
&sk.SKUID, &sk.GoodsID, &sk.SpecRaw, &color, &size, &advice,
&parseOK, &skuCode, &isManual, &sk.CreatedAt, &sk.UpdatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("查询蝦皮规格 %s 失败: %w", skuID, err)
}
sk.Color = color.String
sk.Size = size.String
sk.Advice = advice.String
sk.SKUCode = skuCode.String
sk.ParseOK = parseOK != 0
sk.IsManual = isManual != 0
return &sk, nil
}
// CountShopeeProducts 统计蝦皮商品总数,供列表页判断"是否已导入过任何数据"。
func CountShopeeProducts(q Execer) (int, error) {
var n int
+150 -10
View File
@@ -8,6 +8,7 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
"cmautobuy/admin/model"
)
@@ -288,25 +289,164 @@ func nullableText(s string) any {
// SybOrderFilter 是货运单列表页支持的筛选条件。
type SybOrderFilter struct {
Keyword string // 匹配订单号或商品标题
Stage string // 处理阶段;空字符串表示全部
}
func sybOrderFilterClause(filter SybOrderFilter) (string, []any) {
kw := filter.Keyword
if kw == "" {
return "", nil
var clauses []string
var args []any
if kw := filter.Keyword; kw != "" {
like := "%" + escapeLike(kw) + "%"
clauses = append(clauses, `(so.order_no LIKE ? ESCAPE '\' OR so.title LIKE ? ESCAPE '\')`)
args = append(args, like, like)
}
like := "%" + escapeLike(kw) + "%"
return ` WHERE (order_no LIKE ? ESCAPE '\' OR title LIKE ? ESCAPE '\')`, []any{like, like}
switch filter.Stage {
case "missing_shopee":
clauses = append(clauses, `sp.goods_id IS NULL`)
case "sku_pending":
clauses = append(clauses, `sp.goods_id IS NOT NULL AND so.shopee_sku_id IS NULL`)
case "pdd_missing":
clauses = append(clauses, `so.shopee_sku_id IS NOT NULL AND (sp.pdd_goods_id IS NULL OR sp.pdd_goods_id = '' OR pp.goods_id IS NULL)`)
case "pdd_pending", "pdd_collecting", "pdd_failed", "pdd_collected":
clauses = append(clauses, `so.shopee_sku_id IS NOT NULL AND pp.collect_status = ?`)
args = append(args, strings.TrimPrefix(filter.Stage, "pdd_"))
}
if len(clauses) == 0 {
return "", args
}
return ` WHERE ` + strings.Join(clauses, ` AND `), args
}
const sybOrderContextFrom = `
FROM syb_orders so
LEFT JOIN shopee_products sp ON sp.goods_id = so.shopee_goods_id
LEFT JOIN pdd_products pp ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL`
// SybOrderContext 是顺运宝明细及其当前蝦皮/PDD 处理上下文。
// 处理阶段由 service 计算,Repository 只提供数据库事实。
type SybOrderContext struct {
Order model.SybOrder
ShopeeExists bool
PddGoodsID string
PddGoodsURL string
PddCollectStatus string
PddCollectMsg string
}
func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
var c SybOrderContext
var title, productSpec, shopeeGoodsID, shopeeSKUID, imageURL sql.NullString
var priceCent sql.NullInt64
var shopeeExists int
var pddGoodsID, pddGoodsURL, collectStatus, collectMsg sql.NullString
err := s.Scan(
&c.Order.SybID, &c.Order.OrderNo, &title, &productSpec, &shopeeGoodsID, &shopeeSKUID,
&c.Order.Quantity, &priceCent, &imageURL, &c.Order.SybData,
&c.Order.CreatedAt, &c.Order.UpdatedAt, &shopeeExists,
&pddGoodsID, &pddGoodsURL, &collectStatus, &collectMsg,
)
c.Order.Title = title.String
c.Order.ProductSpec = productSpec.String
c.Order.ShopeeGoodsID = shopeeGoodsID.String
c.Order.ShopeeSKUID = shopeeSKUID.String
c.Order.PriceTwdCent = priceCent.Int64
c.Order.ImageURL = imageURL.String
c.ShopeeExists = shopeeExists != 0
c.PddGoodsID = pddGoodsID.String
c.PddGoodsURL = pddGoodsURL.String
c.PddCollectStatus = collectStatus.String
c.PddCollectMsg = collectMsg.String
return c, err
}
// ListSybOrderContexts 分页读取采购处理工作台所需的关联事实。
func ListSybOrderContexts(q Execer, filter SybOrderFilter, limit, offset int) ([]SybOrderContext, error) {
where, args := sybOrderFilterClause(filter)
query := `
SELECT so.syb_id, so.order_no, so.title, so.product_spec,
so.shopee_goods_id, so.shopee_sku_id, so.quantity,
so.price_twd_cent, so.image_url, so.syb_data, so.created_at, so.updated_at,
CASE WHEN sp.goods_id IS NULL THEN 0 ELSE 1 END,
sp.pdd_goods_id, sp.pdd_goods_url, pp.collect_status, pp.collect_msg` +
sybOrderContextFrom + where + `
ORDER BY so.updated_at DESC, so.syb_id DESC LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := q.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("查询顺运宝采购处理列表失败: %w", err)
}
defer rows.Close()
list := make([]SybOrderContext, 0)
for rows.Next() {
row, err := scanSybOrderContext(rows)
if err != nil {
return nil, fmt.Errorf("读取顺运宝采购处理列表失败: %w", err)
}
list = append(list, row)
}
return list, rows.Err()
}
// GetSybOrderContext 按明细 ID 读取一条处理上下文。
func GetSybOrderContext(q Execer, sybID string) (*SybOrderContext, error) {
row, err := scanSybOrderContext(q.QueryRow(`
SELECT so.syb_id, so.order_no, so.title, so.product_spec,
so.shopee_goods_id, so.shopee_sku_id, so.quantity,
so.price_twd_cent, so.image_url, so.syb_data, so.created_at, so.updated_at,
CASE WHEN sp.goods_id IS NULL THEN 0 ELSE 1 END,
sp.pdd_goods_id, sp.pdd_goods_url, pp.collect_status, pp.collect_msg`+
sybOrderContextFrom+` WHERE so.syb_id = ?`, sybID))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("查询顺运宝明细 %s 失败: %w", sybID, err)
}
return &row, nil
}
// SetSybShopeeSKUIfEmpty 只为尚未确认规格的明细写入自动识别结果。
func SetSybShopeeSKUIfEmpty(q Execer, sybID, skuID string) (bool, error) {
result, err := q.Exec(`
UPDATE syb_orders
SET shopee_sku_id = ?, updated_at = ?
WHERE syb_id = ? AND shopee_sku_id IS NULL
AND EXISTS (
SELECT 1 FROM shopee_skus sk
WHERE sk.sku_id = ? AND sk.goods_id = syb_orders.shopee_goods_id
)`, skuID, model.NowISO(), sybID, skuID)
if err != nil {
return false, fmt.Errorf("自动确认顺运宝明细 %s 的蝦皮规格失败: %w", sybID, err)
}
n, err := result.RowsAffected()
return n > 0, err
}
// SetSybShopeeSKU 在调用方完成商品归属校验后保存人工选择。
func SetSybShopeeSKU(q Execer, sybID, skuID string) error {
result, err := q.Exec(`UPDATE syb_orders SET shopee_sku_id = ?, updated_at = ? WHERE syb_id = ?`,
skuID, model.NowISO(), sybID)
if err != nil {
return fmt.Errorf("保存顺运宝明细 %s 的蝦皮规格失败: %w", sybID, err)
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("顺运宝明细 %s 不存在", sybID)
}
return nil
}
// ListSybOrders 按筛选条件分页查货运单明细列表,按更新时间倒序。
func ListSybOrders(q Execer, filter SybOrderFilter, limit, offset int) ([]model.SybOrder, error) {
where, args := sybOrderFilterClause(filter)
sqlText := `
SELECT syb_id, order_no, title, product_spec, shopee_goods_id, shopee_sku_id,
quantity, price_twd_cent, image_url, syb_data, created_at, updated_at
FROM syb_orders` + where + `
ORDER BY updated_at DESC, syb_id DESC LIMIT ? OFFSET ?`
SELECT so.syb_id, so.order_no, so.title, so.product_spec, so.shopee_goods_id, so.shopee_sku_id,
so.quantity, so.price_twd_cent, so.image_url, so.syb_data, so.created_at, so.updated_at` +
sybOrderContextFrom + where + `
ORDER BY so.updated_at DESC, so.syb_id DESC LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := q.Query(sqlText, args...)
@@ -343,7 +483,7 @@ func ListSybOrders(q Execer, filter SybOrderFilter, limit, offset int) ([]model.
// 靠它,写成两份筛选条件迟早有一天会不一致(工单 #43 的教训)。
func CountSybOrders(q Execer, filter SybOrderFilter) (int, error) {
where, args := sybOrderFilterClause(filter)
sqlText := `SELECT COUNT(*) FROM syb_orders` + where
sqlText := `SELECT COUNT(*)` + sybOrderContextFrom + where
var n int
if err := q.QueryRow(sqlText, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("统计顺运宝货运单数量失败: %w", err)
+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 登录成功后把会话缓存进数据库。
//
// `[必须]` 缓存写失败不能让已经登录的会话失效——本函数把错误原样
+137
View File
@@ -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])
}
}
}
+31 -21
View File
@@ -203,33 +203,43 @@
var slot = modal.querySelector("[data-detail-slot]");
var base = modal.getAttribute("data-detail-url") || "";
function loadDetail(id) {
slot.innerHTML = "<div class='modal-body'>正在加载…</div>";
openModal(modal);
var separator = base.indexOf("?") === -1 ? "?" : "&";
fetch(base + separator + "id=" + encodeURIComponent(id), {
headers: { "X-Requested-With": "fetch" }
})
.then(function (resp) {
if (!resp.ok) throw new Error("HTTP " + resp.status);
return resp.text();
})
.then(function (html) {
slot.innerHTML = html;
})
.catch(function (err) {
/* 出错也要说清楚,不能让弹窗一直停在"正在加载…" */
slot.innerHTML =
"<div class='modal-body'><p class='missing'>打不开详情:" +
err.message +
"。刷新页面后重试。</p></div>" +
"<div class='modal-foot'><button type='button' data-modal-close>关闭</button></div>";
});
}
document.querySelectorAll("tr[data-detail-id]").forEach(function (row) {
row.addEventListener("dblclick", function (e) {
/* 双击到勾选框或链接上时不要弹窗,那是另一个动作 */
if (e.target.closest("input, a, button")) return;
slot.innerHTML = "<div class='modal-body'>正在加载…</div>";
openModal(modal);
loadDetail(row.getAttribute("data-detail-id"));
});
});
var separator = base.indexOf("?") === -1 ? "?" : "&";
fetch(base + separator + "id=" + encodeURIComponent(row.getAttribute("data-detail-id")), {
headers: { "X-Requested-With": "fetch" }
})
.then(function (resp) {
if (!resp.ok) throw new Error("HTTP " + resp.status);
return resp.text();
})
.then(function (html) {
slot.innerHTML = html;
})
.catch(function (err) {
/* 出错也要说清楚,不能让弹窗一直停在"正在加载…" */
slot.innerHTML =
"<div class='modal-body'><p class='missing'>打不开详情:" +
err.message +
"。刷新页面后重试。</p></div>" +
"<div class='modal-foot'><button type='button' data-modal-close>关闭</button></div>";
});
document.querySelectorAll("[data-detail-open-id]").forEach(function (button) {
button.addEventListener("click", function () {
loadDetail(button.getAttribute("data-detail-open-id"));
});
});
}
+54
View File
@@ -0,0 +1,54 @@
{{define "syb/detail_modal"}}
{{with .D}}
<div class="modal-head">
<h2>处理订单 {{.OrderNo}}</h2>
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
</div>
<div class="modal-body form-stack">
<p><span class="status-pill">{{.StageText}}</span></p>
<p class="hint">{{.StageHelp}}</p>
<dl class="detail">
<dt>明细 ID</dt><dd>{{.SybID}}</dd>
<dt>商品</dt><dd>{{.Title}}</dd>
<dt>顺运宝规格</dt><dd>{{.ProductSpec}}</dd>
<dt>数量</dt><dd>{{.Quantity}}</dd>
<dt>蝦皮商品 ID</dt><dd>{{.ShopeeGoodsID}}</dd>
<dt>蝦皮规格 ID</dt><dd>{{if .ShopeeSKUID}}{{.ShopeeSKUID}}{{else}}—{{end}}</dd>
</dl>
{{if not .ShopeeExists}}
<p class="missing">蝦皮数据中找不到商品 {{.ShopeeGoodsID}}。请先导入该商品,或核对顺运宝商品链接提取出的 ID。</p>
<p><a class="button-link" href="/shopee?goods_id={{.ShopeeGoodsID}}">到蝦皮数据查看</a></p>
{{else if not .Candidates}}
<p class="missing">这个蝦皮商品没有可选 SKU。请先在蝦皮数据中补充规格。</p>
<p><a class="button-link" href="/shopee?goods_id={{.ShopeeGoodsID}}">到蝦皮数据补充</a></p>
{{else}}
<form method="post" action="/syb/select-sku" class="form-stack">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="syb_id" value="{{.SybID}}">
<input type="hidden" name="order_no" value="{{$.Keyword}}">
<input type="hidden" name="stage" value="{{$.StageFilter}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}">
<div class="field">
<label for="syb-shopee-sku">蝦皮规格</label>
<select id="syb-shopee-sku" name="shopee_sku_id" required autofocus>
<option value="">请选择</option>
{{range .Candidates}}
<option value="{{.SKUID}}" {{if .Selected}}selected{{end}}>{{.SpecRaw}}({{.Color}} / {{.Size}},ID {{.SKUID}})</option>
{{end}}
</select>
<small>系统只会自动采用唯一精确候选;这里的选择仍会由服务端校验是否属于当前商品。</small>
</div>
<div>
<button type="submit" class="primary">确认蝦皮规格</button>
</div>
</form>
{{end}}
</div>
<div class="modal-foot">
<button type="button" data-modal-close>关闭</button>
</div>
{{end}}
{{end}}
+29 -8
View File
@@ -5,6 +5,8 @@
<form class="inline sync-range-form" method="post" action="/syb/sync" data-syb-sync-form>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="order_no" value="{{.Keyword}}">
<input type="hidden" name="stage" value="{{.StageFilter}}">
<input type="hidden" name="page" value="{{.CurrentPage}}">
<label for="sync-date-from">开始日期</label>
<input id="sync-date-from" type="date" name="date_from"
value="{{.SyncDateFrom}}" max="{{.Today}}" required>
@@ -21,10 +23,19 @@
<form class="inline" method="post" action="/syb/create-task">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="order_no" value="{{.Keyword}}">
<input type="hidden" name="stage" value="{{.StageFilter}}">
<input type="hidden" name="page" value="{{.CurrentPage}}">
<button type="submit" data-need-checked>创建采购任务</button>
</form>
<form class="inline grow" method="get" action="/syb">
<label for="stage">处理阶段</label>
<select id="stage" name="stage">
{{range .StageOptions}}
<option value="{{.Value}}" {{if eq .Value $.StageFilter}}selected{{end}}>{{.Text}}</option>
{{end}}
</select>
<label for="q">订单号</label>
{{/* search-narrow 把搜索框收到 30%,让「搜索」按钮紧挨着它。
用类名而不是改 .toolbar input[type="text"]——那条被五个页面共用。 */}}
@@ -35,6 +46,9 @@
<form class="inline" method="post" action="/syb/delete" data-confirm-delete>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="order_no" value="{{.Keyword}}">
<input type="hidden" name="stage" value="{{.StageFilter}}">
<input type="hidden" name="page" value="{{.CurrentPage}}">
<button type="submit" class="danger" data-need-checked>删除</button>
</form>
</div>
@@ -87,13 +101,14 @@
<th>数量</th>
<th>价格</th>
<th>图片</th>
<th>匹配状态</th>
<th>处理阶段</th>
<th>下一步</th>
<th>更新时间</th>
</tr>
</thead>
<tbody>
{{range .Rows}}
<tr data-detail-id="{{.SybID}}" {{if not .Matched}}class="row-warn"{{end}}>
<tr data-detail-id="{{.SybID}}" {{if .NeedsAttention}}class="row-warn"{{end}}>
<td class="col-check">
<input type="checkbox" value="{{.SybID}}" name="ids"
aria-label="选择货运单明细 {{.SybID}}">
@@ -103,20 +118,19 @@
<td class="truncate" title="{{.Title}}">{{.Title}}</td>
<td class="truncate" title="{{.ProductSpec}}">{{.ProductSpec}}</td>
<td>{{.ShopeeGoodsID}}</td>
{{/* 匹配状态是算出来的(shopee_sku_id 是否非空),本工单不做匹配功能,
待匹配的行整行标黄提醒,但不提供匹配入口——那是后续工单的范围 */}}
<td>{{if .ShopeeSKUID}}{{.ShopeeSKUID}}{{else}}—{{end}}</td>
<td>{{.Quantity}}</td>
<td>{{.PriceText}}</td>
<td>
{{if .ImageURL}}<img src="{{.ImageURL}}" alt="" class="thumb">{{else}}—{{end}}
</td>
<td>{{.MatchText}}</td>
<td><span class="status-pill">{{.StageText}}</span><br><small>{{.StageHelp}}</small></td>
<td><button type="button" data-detail-open-id="{{.SybID}}">{{.ActionText}}</button></td>
<td>{{.UpdatedAt}}</td>
</tr>
{{else}}
<tr class="empty">
<td colspan="12">
<td colspan="13">
{{if .IsFiltered}}
当前筛选条件下没有货运单明细。<br>
<small>换个订单号或清空搜索词再试。<a href="/syb">查看全部</a></small>
@@ -132,10 +146,15 @@
</div>
<p class="hint">
「匹配状态」是根据规格 SKU 是否已填算出来的,本页暂不提供匹配入口
(规格匹配是后续工单的范围)。
每行只显示当前最需要完成的下一步;双击行或点击“下一步”按钮可以打开处理详情。
</p>
<div class="modal-backdrop" id="detail-modal" data-detail-url="{{.DetailURL}}" hidden>
<div class="modal modal-wide" role="dialog" aria-modal="true" data-detail-slot>
<div class="modal-body">正在加载…</div>
</div>
</div>
{{/* ── 登录弹窗 ─────────────────────────────────
会话未登录/已过期时自动打开(不加 hidden);
`[必须]` 密码不在界面上显示、也不回显到 HTML,只显示只读账号。 */}}
@@ -148,6 +167,8 @@
<form method="post" action="/syb/login-and-sync">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="order_no" value="{{.Keyword}}">
<input type="hidden" name="stage" value="{{.StageFilter}}">
<input type="hidden" name="page" value="{{.CurrentPage}}">
{{/* 登录和 OCR 降级后必须把工具栏里已确认的日期范围原样带回来。 */}}
<input type="hidden" name="date_from" value="{{.SyncDateFrom}}">
<input type="hidden" name="date_to" value="{{.SyncDateTo}}">
+6 -2
View File
@@ -200,12 +200,16 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
| 数量 | |
| 价格 | 蝦皮售价,**台币分**,见 §7 |
| 图片 | 存 URL,表格里显示缩略图 |
| 匹配状态 | 已匹配 / 待匹配 |
| 处理阶段 | 未找到蝦皮商品 / 待确认蝦皮规格 / 未关联 PDD / 待采集 / 采集中 / 采集失败 / 规格待匹配 / 可采购 |
| 下一步 | 每行只显示当前最需要完成的一个操作 |
| 更新时间 | |
原始的完整货运单 JSON 存 `syb_data` 字段,**不作为表格列显示**,在详情里看。
**双击行打开匹配弹窗:**
**双击行或点击下一步打开处理弹窗:**
- 顺运宝商品 ID 能找到蝦皮商品后,先确认蝦皮 SKU。规格原文完全一致,或解析后的颜色尺码只有一个唯一候选时可以自动确认;零候选和多候选必须人工选择,不能猜。
- `shopee_sku_id` 只表示“蝦皮规格已确认”,不等于 PDD 规格已经匹配。完整处理阶段还要检查当前 PDD 关联、采集状态和 `sku_mappings`。
- 左边显示蝦皮的颜色尺码,右边显示该商品 `pdd_data` 里的 PDD 颜色尺码;
- 操作员选好对应关系,点保存;
+11 -7
View File
@@ -472,20 +472,24 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
### 6.2 表格列
☐ / 货运单明细ID / 订单号 / 商品标题 / 规格 / 蝦皮商品ID / 规格SKU / 数量 /
价格(台币)/ 图片 / **匹配状态** / 更新时间
价格(台币)/ 图片 / **处理阶段** / **下一步** / 更新时间
- 一行对应顺运宝一张货运单的**一个商品明细**,不是一张货运单——一张货运单
可以有多个商品,各占一行。
- 图片显示小缩略图。`[必须]` 存 URL,不要把图片塞进数据库。
- 匹配状态是**算出来的**(`shopee_sku_id` 是否非空),不是存的字段。
本工单(#46)不做规格匹配功能,待匹配的行整行标黄,但不提供匹配入口。
- 处理阶段是按当前关联事实**算出来的**,不是存一份容易过期的状态字段。阶段至少区分:
未找到蝦皮商品、待确认蝦皮规格、未关联 PDD、PDD 待采集、采集中、采集失败、
规格待匹配和可采购。`shopee_sku_id` 非空只说明蝦皮规格已确认。
- 每行显示一个有文字的“下一步”按钮;双击行和按钮打开同一个服务端渲染处理弹窗。
- 规格原文完全一致,或解析后的颜色尺码只有一个唯一候选时,系统可以自动确认
蝦皮 SKU;零候选或多候选必须由采购员选择,不能模糊猜测。
- 完整货运单 JSON 不作为列显示,落在 `syb_data` 里,供后续排查用。
### 6.3 规格匹配弹窗(双击行打开)—— 未实现,见下方说明
### 6.3 采购处理与规格匹配弹窗(双击行或点击下一步打开)
`[不做]` 工单 #46(顺运宝货运单同步)明确不做这一节描述的匹配弹窗,
`shopee_sku_id` 由同步留空,本节描述的是**规格匹配**这个后续工单要实现的目标
界面,先记录在这里,不代表当前已经能用。
弹窗按当前处理阶段渐进展示。第一步先确认蝦皮 SKU;顺运宝同步本身仍不提供
`shopee_sku_id`,但同步写入明细后可以根据本地蝦皮数据做确定性唯一匹配。
后续 PDD 关联、采集和规格映射使用同一个弹窗继续完成。
```text
┌──────────────────────────────────────────────────┐