feat: 建立顺运宝采购处理状态 (#67)
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 登录成功后把会话缓存进数据库。
|
||||
//
|
||||
// `[必须]` 缓存写失败不能让已经登录的会话失效——本函数把错误原样
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}">
|
||||
|
||||
@@ -200,12 +200,16 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
||||
| 数量 | |
|
||||
| 价格 | 蝦皮售价,**台币分**,见 §7 |
|
||||
| 图片 | 存 URL,表格里显示缩略图 |
|
||||
| 匹配状态 | 已匹配 / 待匹配 |
|
||||
| 处理阶段 | 未找到蝦皮商品 / 待确认蝦皮规格 / 未关联 PDD / 待采集 / 采集中 / 采集失败 / 规格待匹配 / 可采购 |
|
||||
| 下一步 | 每行只显示当前最需要完成的一个操作 |
|
||||
| 更新时间 | |
|
||||
|
||||
原始的完整货运单 JSON 存 `syb_data` 字段,**不作为表格列显示**,在详情里看。
|
||||
|
||||
**双击行打开匹配弹窗:**
|
||||
**双击行或点击下一步打开处理弹窗:**
|
||||
|
||||
- 顺运宝商品 ID 能找到蝦皮商品后,先确认蝦皮 SKU。规格原文完全一致,或解析后的颜色尺码只有一个唯一候选时可以自动确认;零候选和多候选必须人工选择,不能猜。
|
||||
- `shopee_sku_id` 只表示“蝦皮规格已确认”,不等于 PDD 规格已经匹配。完整处理阶段还要检查当前 PDD 关联、采集状态和 `sku_mappings`。
|
||||
|
||||
- 左边显示蝦皮的颜色尺码,右边显示该商品 `pdd_data` 里的 PDD 颜色尺码;
|
||||
- 操作员选好对应关系,点保存;
|
||||
|
||||
@@ -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
|
||||
┌──────────────────────────────────────────────────┐
|
||||
|
||||
Reference in New Issue
Block a user