feat: 支持人工修改蝦皮规格 (#215)
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -119,6 +120,7 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, searchFieldRaw, stat
|
||||
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
|
||||
"CurrentPage": result.Page,
|
||||
"DetailURL": "/shopee/detail?" + detailValuesForShopee(values, result.Page),
|
||||
"AutoOpenDetailID": strings.TrimSpace(c.Query("open_id")),
|
||||
"AssignableClients": assignableClients,
|
||||
}))
|
||||
}
|
||||
@@ -158,6 +160,22 @@ func (h *Handler) ShopeeSave(c *gin.Context) {
|
||||
h.shopeeRedirect(c, fmt.Sprintf("已关联 PDD 商品 %s,可继续创建采集任务", goodsID))
|
||||
}
|
||||
|
||||
// ShopeeSpecSave 保存一条现有蝦皮 SKU 的人工颜色、尺码和建议说明。
|
||||
func (h *Handler) ShopeeSpecSave(c *gin.Context) {
|
||||
err := service.UpdateShopeeSKUManual(h.db, currentUser(c), c.PostForm("shopee_goods_id"),
|
||||
c.PostForm("sku_record_id"), c.PostForm("color"), c.PostForm("size"),
|
||||
c.PostForm("advice"), time.Now())
|
||||
if err != nil {
|
||||
if service.IsValidationError(err) {
|
||||
h.shopeeRedirect(c, "规格未保存:"+err.Error())
|
||||
return
|
||||
}
|
||||
fail(c, http.StatusInternalServerError, "保存蝦皮规格失败,数据没有被改动。请刷新页面后重试。")
|
||||
return
|
||||
}
|
||||
h.shopeeRedirect(c, "蝦皮规格已保存,人工字段不会被后续导入覆盖")
|
||||
}
|
||||
|
||||
// ShopeeDelete 批量删除勾选的行。
|
||||
func (h *Handler) ShopeeDelete(c *gin.Context) {
|
||||
count, err := service.DeleteShopeeProducts(h.db, currentUser(c), c.PostFormArray("ids"))
|
||||
@@ -246,6 +264,9 @@ func (h *Handler) shopeeRedirect(c *gin.Context, msg string) {
|
||||
if value := strings.TrimSpace(c.PostForm("page")); value != "" {
|
||||
params.Set("page", value)
|
||||
}
|
||||
if value := strings.TrimSpace(c.PostForm("open_id")); value != "" {
|
||||
params.Set("open_id", value)
|
||||
}
|
||||
if msg != "" {
|
||||
params.Set("msg", msg)
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
|
||||
pages.GET("/shopee", h.ShopeeList)
|
||||
pages.GET("/shopee/detail", h.ShopeeDetail) // 双击行时前端来取弹窗内容
|
||||
pages.POST("/shopee/save", h.ShopeeSave)
|
||||
pages.POST("/shopee/spec/save", h.ShopeeSpecSave)
|
||||
pages.POST("/shopee/collect", h.ShopeeCollect)
|
||||
pages.POST("/shopee/collect-batch", h.ShopeeCollectBatch)
|
||||
shopeeAdmin := pages.Group("/shopee", AdminRequired())
|
||||
|
||||
@@ -385,6 +385,27 @@ func TestShopeePage_展示主图店铺且保留标题宽度(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShopeeDetail_现有SKU支持渐进式人工编辑(t *testing.T) {
|
||||
detail, err := os.ReadFile("templates/shopee/detail_modal.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
routes, err := os.ReadFile("handler/web/web.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(detail) + string(routes)
|
||||
for _, want := range []string{
|
||||
`action="/shopee/spec/save"`, `name="sku_record_id"`, `name="open_id"`,
|
||||
`name="color"`, `name="size"`, `name="advice"`, `maxlength="500"`,
|
||||
`规格原文和 SKU 身份不会修改`, `pages.POST("/shopee/spec/save"`,
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Errorf("蝦皮规格编辑界面或路由缺少 %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShopeePage_分类搜索回收站和状态保留(t *testing.T) {
|
||||
list, err := os.ReadFile("templates/shopee/list.html")
|
||||
if err != nil {
|
||||
|
||||
@@ -351,7 +351,8 @@ func UpsertCatalogShopeeSKU(q Execer, in CatalogShopeeSKUInput) (CatalogSKUOutco
|
||||
sources, times := catalogFieldProvenance(current)
|
||||
changed := external != current.ShopeeSKUID.String
|
||||
canFill := func(field, current, incoming string) bool {
|
||||
return strings.TrimSpace(incoming) != "" && (current == "" || (sources[field] == "syb" && in.Source != "syb"))
|
||||
return sources[field] != "manual" && strings.TrimSpace(incoming) != "" &&
|
||||
(current == "" || (sources[field] == "syb" && in.Source != "syb"))
|
||||
}
|
||||
if canFill("color", color, in.Color) {
|
||||
color = in.Color
|
||||
|
||||
@@ -438,6 +438,50 @@ func GetShopeeSKUByID(q Execer, skuID string) (*model.ShopeeSKU, error) {
|
||||
return &sk, nil
|
||||
}
|
||||
|
||||
// ShopeeSKUManualState 是人工编辑前需要读取的最小状态。
|
||||
// 字段来源 JSON 必须先读出再合并,不能直接重建,否则会丢掉 sku_code 等
|
||||
// 本次表单没有修改的字段来源。
|
||||
type ShopeeSKUManualState struct {
|
||||
RecordID, GoodsID string
|
||||
FieldSources, FieldObservedAt string
|
||||
}
|
||||
|
||||
// GetShopeeSKUManualState 查询一条仍属于正常商品的 SKU 及其字段来源。
|
||||
// 查不到返回 (nil, nil)。
|
||||
func GetShopeeSKUManualState(q Execer, skuID string) (*ShopeeSKUManualState, error) {
|
||||
var state ShopeeSKUManualState
|
||||
err := q.QueryRow(`SELECT sku_id,goods_id,COALESCE(field_sources,'{}'),COALESCE(field_observed_at,'{}')
|
||||
FROM shopee_skus sk WHERE sku_id=?
|
||||
AND EXISTS (SELECT 1 FROM shopee_products sp WHERE sp.goods_id=sk.goods_id AND sp.deleted_at IS NULL)`, skuID).
|
||||
Scan(&state.RecordID, &state.GoodsID, &state.FieldSources, &state.FieldObservedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询待编辑蝦皮规格 %s 失败: %w", skuID, err)
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
// UpdateShopeeSKUManualFields 原子保存人工确认的颜色、尺码和建议说明。
|
||||
// spec_raw、spec_key、真实 SKU ID、SKU 编码和 is_manual 均不在更新清单中。
|
||||
func UpdateShopeeSKUManualFields(q Execer, skuID, goodsID, color, size, advice,
|
||||
fieldSources, fieldObservedAt, updatedAt string) (bool, error) {
|
||||
result, err := q.Exec(`UPDATE shopee_skus SET color=?,size=?,advice=NULLIF(?,''),parse_ok=1,
|
||||
field_sources=?,field_observed_at=?,updated_at=?
|
||||
WHERE sku_id=? AND goods_id=?
|
||||
AND EXISTS (SELECT 1 FROM shopee_products sp WHERE sp.goods_id=shopee_skus.goods_id AND sp.deleted_at IS NULL)`,
|
||||
color, size, advice, fieldSources, fieldObservedAt, updatedAt, skuID, goodsID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("保存蝦皮规格 %s 失败: %w", skuID, err)
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("确认蝦皮规格 %s 保存结果失败: %w", skuID, err)
|
||||
}
|
||||
return count == 1, nil
|
||||
}
|
||||
|
||||
// CountShopeeProducts 统计蝦皮商品总数,供列表页判断"是否已导入过任何数据"。
|
||||
func CountShopeeProducts(q Execer) (int, error) {
|
||||
var n int
|
||||
|
||||
@@ -214,17 +214,16 @@ func ParseShopeeSearchField(s string) string {
|
||||
|
||||
// ShopeeSpecView 是弹窗规格表的一行。
|
||||
type ShopeeSpecView struct {
|
||||
SKUID string
|
||||
Color string
|
||||
Size string
|
||||
Advice string
|
||||
RecordID string
|
||||
SKUID string
|
||||
Color string
|
||||
Size string
|
||||
Advice string
|
||||
// StatusText 是"✓"或"待补",`[必须]` 要有文字,不能只靠颜色区分(见 #41)。
|
||||
StatusText string
|
||||
// Pending 为 true 时模板整行标黄,并在下面加一行显示 SpecRaw 原文。
|
||||
// Pending 为 true 时模板整行标黄。
|
||||
Pending bool
|
||||
// SpecRaw 只在 Pending 为 true 时才有意义。
|
||||
// `[必须]` 待补的行必须显示规格原文——不显示的话人工不知道该填什么,
|
||||
// 这正是保留 spec_raw 这个设计的全部意义,见 #41。
|
||||
// SpecRaw 对所有行保持只读展示,人工编辑时用它核对颜色和尺码。
|
||||
SpecRaw string
|
||||
}
|
||||
|
||||
@@ -317,18 +316,19 @@ func GetShopeeProductDetail(db *sql.DB, goodsID string) (*ShopeeProductDetail, e
|
||||
}
|
||||
d.SKUs = make([]ShopeeSpecView, 0, len(skus))
|
||||
for _, sk := range skus {
|
||||
row := ShopeeSpecView{SKUID: sk.SKUID}
|
||||
row := ShopeeSpecView{
|
||||
RecordID: sk.RecordID,
|
||||
SKUID: sk.SKUID,
|
||||
Color: sk.Color,
|
||||
Size: sk.Size,
|
||||
Advice: sk.Advice,
|
||||
SpecRaw: sk.SpecRaw,
|
||||
}
|
||||
if sk.ParseOK {
|
||||
row.Color, row.Size, row.Advice = sk.Color, sk.Size, sk.Advice
|
||||
if row.Advice == "" {
|
||||
row.Advice = placeholder
|
||||
}
|
||||
row.StatusText = "✓"
|
||||
} else {
|
||||
row.Color, row.Size, row.Advice = placeholder, placeholder, placeholder
|
||||
row.StatusText = "待补"
|
||||
row.Pending = true
|
||||
row.SpecRaw = sk.SpecRaw
|
||||
d.PendingCount++
|
||||
}
|
||||
d.SKUs = append(d.SKUs, row)
|
||||
|
||||
@@ -297,6 +297,9 @@ func TestGetShopeeProductDetail_待补行显示原文(t *testing.T) {
|
||||
if pending.SpecRaw != "紅色,3XL建議80-90公斤】" {
|
||||
t.Errorf("SpecRaw = %q,想要显示原文", pending.SpecRaw)
|
||||
}
|
||||
if pending.RecordID != "s2" || pending.Color != "" || pending.Size != "" {
|
||||
t.Errorf("待补规格的编辑值或内部定位错误:%+v", pending)
|
||||
}
|
||||
if pending.StatusText != "待补" {
|
||||
t.Errorf("StatusText = %q,想要 \"待补\"", pending.StatusText)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
const maxShopeeSKUFieldRunes = 500
|
||||
|
||||
// UpdateShopeeSKUManual 保存采购员对现有蝦皮规格的人工修正。
|
||||
// 只修改颜色、尺码和建议说明;规格原文、身份字段和既有采购数据保持不变。
|
||||
func UpdateShopeeSKUManual(db *sql.DB, actor *model.User, goodsID, skuRecordID,
|
||||
color, size, advice string, now time.Time) error {
|
||||
if actor == nil || actor.Status != model.UserActive {
|
||||
return ErrUnauthenticated
|
||||
}
|
||||
goodsID = strings.TrimSpace(goodsID)
|
||||
skuRecordID = strings.TrimSpace(skuRecordID)
|
||||
color = strings.TrimSpace(color)
|
||||
size = strings.TrimSpace(size)
|
||||
advice = strings.TrimSpace(advice)
|
||||
if goodsID == "" {
|
||||
return invalidFieldInput("shopee_goods_id", "蝦皮商品编号不能为空")
|
||||
}
|
||||
if skuRecordID == "" {
|
||||
return invalidFieldInput("sku_record_id", "蝦皮规格记录编号不能为空")
|
||||
}
|
||||
if color == "" {
|
||||
return invalidFieldInput("color", "颜色不能为空")
|
||||
}
|
||||
if size == "" {
|
||||
return invalidFieldInput("size", "尺码不能为空")
|
||||
}
|
||||
for _, field := range []struct {
|
||||
name, label, value string
|
||||
}{{"color", "颜色", color}, {"size", "尺码", size}, {"advice", "建议说明", advice}} {
|
||||
if utf8.RuneCountInString(field.value) > maxShopeeSKUFieldRunes {
|
||||
return invalidFieldInput(field.name, "%s最多 500 个字符", field.label)
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
state, err := repository.GetShopeeSKUManualState(tx, skuRecordID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state == nil || state.GoodsID != goodsID {
|
||||
return invalidFieldInput("sku_record_id", "蝦皮规格不存在或不属于当前商品,请刷新页面后重试")
|
||||
}
|
||||
sources, err := decodeShopeeSKUProvenance(state.FieldSources)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取蝦皮规格字段来源失败: %w", err)
|
||||
}
|
||||
observedTimes, err := decodeShopeeSKUProvenance(state.FieldObservedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取蝦皮规格字段时间失败: %w", err)
|
||||
}
|
||||
at := now.UTC().Format(model.TimeLayout)
|
||||
for _, field := range []string{"color", "size", "advice"} {
|
||||
sources[field] = "manual"
|
||||
observedTimes[field] = at
|
||||
}
|
||||
sourcesJSON, err := json.Marshal(sources)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
timesJSON, err := json.Marshal(observedTimes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated, err := repository.UpdateShopeeSKUManualFields(tx, skuRecordID, goodsID,
|
||||
color, size, advice, string(sourcesJSON), string(timesJSON), at)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !updated {
|
||||
return invalidFieldInput("sku_record_id", "蝦皮规格已发生变化,请刷新页面后重试")
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func decodeShopeeSKUProvenance(raw string) (map[string]string, error) {
|
||||
values := map[string]string{}
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return values, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
func TestUpdateShopeeSKUManual_保存并保留身份字段(t *testing.T) {
|
||||
db := newCatalogTestDB(t)
|
||||
actor := &model.User{UserID: "USER-1", Role: model.RolePurchaser, Status: model.UserActive}
|
||||
goodsID, recordID := "S-EDIT", "REC-EDIT"
|
||||
nowText := "2026-08-12T00:00:00Z"
|
||||
if _, err := db.Exec(`INSERT INTO shopee_products(goods_id,title,source,created_at,updated_at) VALUES(?,?,'api',?,?)`, goodsID, "测试商品", nowText, nowText); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO shopee_skus(sku_id,shopee_sku_id,goods_id,spec_raw,spec_key,color,size,advice,parse_ok,sku_code,is_manual,source,field_sources,field_observed_at,source_observed_at,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, recordID, "REAL-SKU-1", goodsID, "旧黑色,M【建议50公斤】", "稳定规格键",
|
||||
"旧黑色", "M", "旧建议", 0, "CODE-1", 0, "partner",
|
||||
`{"color":"partner","size":"partner","advice":"partner","sku_code":"partner"}`,
|
||||
`{"color":"2026-08-12T00:00:00Z","size":"2026-08-12T00:00:00Z","advice":"2026-08-12T00:00:00Z","sku_code":"2026-08-12T00:00:00Z"}`,
|
||||
nowText, nowText, nowText); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedAt := time.Date(2026, 8, 13, 3, 4, 5, 0, time.UTC)
|
||||
if err := UpdateShopeeSKUManual(db, actor, goodsID, recordID, " 新黑色 ", " L ", "", updatedAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var shopeeSKU, specRaw, specKey, color, size, advice, skuCode, source, sourcesRaw, timesRaw, storedAt string
|
||||
var parseOK, isManual int
|
||||
if err := db.QueryRow(`SELECT shopee_sku_id,spec_raw,spec_key,color,size,COALESCE(advice,''),parse_ok,sku_code,is_manual,source,field_sources,field_observed_at,updated_at FROM shopee_skus WHERE sku_id=?`, recordID).
|
||||
Scan(&shopeeSKU, &specRaw, &specKey, &color, &size, &advice, &parseOK, &skuCode, &isManual, &source, &sourcesRaw, ×Raw, &storedAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if color != "新黑色" || size != "L" || advice != "" || parseOK != 1 {
|
||||
t.Fatalf("人工字段未正确保存:color=%q size=%q advice=%q parse=%d", color, size, advice, parseOK)
|
||||
}
|
||||
if shopeeSKU != "REAL-SKU-1" || specRaw != "旧黑色,M【建议50公斤】" || specKey != "稳定规格键" || skuCode != "CODE-1" || isManual != 0 || source != "partner" {
|
||||
t.Fatalf("不可变身份被修改:sku=%q raw=%q key=%q code=%q manual=%d source=%q", shopeeSKU, specRaw, specKey, skuCode, isManual, source)
|
||||
}
|
||||
var sources, times map[string]string
|
||||
if err := json.Unmarshal([]byte(sourcesRaw), &sources); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(timesRaw), ×); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, field := range []string{"color", "size", "advice"} {
|
||||
if sources[field] != "manual" || times[field] != updatedAt.Format(model.TimeLayout) {
|
||||
t.Fatalf("%s 的人工来源错误:sources=%v times=%v", field, sources, times)
|
||||
}
|
||||
}
|
||||
if sources["sku_code"] != "partner" || storedAt != updatedAt.Format(model.TimeLayout) {
|
||||
t.Fatalf("未编辑字段来源或更新时间错误:sources=%v updated=%q", sources, storedAt)
|
||||
}
|
||||
|
||||
for _, policy := range []string{"fill_missing", "overwrite_same_source"} {
|
||||
outcome, err := repository.UpsertCatalogShopeeSKU(db, repository.CatalogShopeeSKUInput{
|
||||
RecordID: "IGNORED", ShopeeSKUID: "REAL-SKU-1", GoodsID: goodsID,
|
||||
SpecRaw: specRaw, SpecKey: specKey, Color: "导入黑色", Size: "XL", Advice: "导入重新填入的建议",
|
||||
ParseOK: true, SKUCode: "CODE-2", Source: "partner", ObservedAt: "2026-08-14T00:00:00Z",
|
||||
Now: "2026-08-14T00:00:00Z", UpdatePolicy: policy,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s 导入失败:%v", policy, err)
|
||||
}
|
||||
if outcome != repository.CatalogSKUFilled && outcome != repository.CatalogSKUSkipped && outcome != repository.CatalogSKUSameSourceUpdated {
|
||||
t.Fatalf("%s 返回意外结果:%s", policy, outcome)
|
||||
}
|
||||
}
|
||||
if err := db.QueryRow(`SELECT color,size,COALESCE(advice,'') FROM shopee_skus WHERE sku_id=?`, recordID).Scan(&color, &size, &advice); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if color != "新黑色" || size != "L" || advice != "" {
|
||||
t.Fatalf("人工字段被后续导入覆盖:%q/%q/%q", color, size, advice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateShopeeSKUManual_拒绝无效输入且不改数据(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
actor *model.User
|
||||
goodsID, recordID string
|
||||
color, size string
|
||||
}{
|
||||
{"未登录", nil, "S-EDIT", "REC-EDIT", "黑色", "M"},
|
||||
{"停用账号", &model.User{Status: model.UserDisabled}, "S-EDIT", "REC-EDIT", "黑色", "M"},
|
||||
{"规格不存在", &model.User{Status: model.UserActive}, "S-EDIT", "NOT-FOUND", "黑色", "M"},
|
||||
{"跨商品", &model.User{Status: model.UserActive}, "S-OTHER", "REC-EDIT", "黑色", "M"},
|
||||
{"颜色为空", &model.User{Status: model.UserActive}, "S-EDIT", "REC-EDIT", " ", "M"},
|
||||
{"尺码为空", &model.User{Status: model.UserActive}, "S-EDIT", "REC-EDIT", "黑色", " "},
|
||||
{"颜色过长", &model.User{Status: model.UserActive}, "S-EDIT", "REC-EDIT", strings.Repeat("色", 501), "M"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := newCatalogTestDB(t)
|
||||
now := "2026-08-12T00:00:00Z"
|
||||
if _, err := db.Exec(`INSERT INTO shopee_products(goods_id,title,source,created_at,updated_at) VALUES('S-EDIT','测试','api',?,?)`, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO shopee_skus(sku_id,goods_id,spec_raw,spec_key,color,size,parse_ok,is_manual,source,field_sources,field_observed_at,created_at,updated_at)
|
||||
VALUES('REC-EDIT','S-EDIT','黑色,M','黑色,M','旧色','旧码',1,0,'partner','{}','{}',?,?)`, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := UpdateShopeeSKUManual(db, tc.actor, tc.goodsID, tc.recordID, tc.color, tc.size, "建议", time.Now())
|
||||
if err == nil {
|
||||
t.Fatal("期望拒绝无效输入")
|
||||
}
|
||||
var color, size string
|
||||
if err := db.QueryRow(`SELECT color,size FROM shopee_skus WHERE sku_id='REC-EDIT'`).Scan(&color, &size); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if color != "旧色" || size != "旧码" {
|
||||
t.Fatalf("失败后发生部分更新:%q/%q", color, size)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,22 @@ tr.empty small { color: #aaa; }
|
||||
.thumb-placeholder, .thumb-fallback { color: #6b7280; font-size: 12px; white-space: nowrap; }
|
||||
.detail-product-image { width: 120px; max-height: 120px; object-fit: contain; border: 1px solid #ccd1d6; border-radius: 4px; }
|
||||
|
||||
/* 蝦皮 SKU 默认保持只读摘要,只在当前行展开编辑,避免规格多时整张表
|
||||
被输入框淹没。表格自身负责横向滚动。 */
|
||||
.sku-editor > summary { color: #1f5f99; cursor: pointer; }
|
||||
.sku-editor[open] > summary { margin-bottom: 8px; }
|
||||
.sku-edit-form { min-width: 360px; white-space: normal; }
|
||||
.sku-edit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sku-edit-field { display: flex; flex-direction: column; gap: 4px; color: #555; }
|
||||
.sku-edit-field input { min-width: 0; width: 100%; }
|
||||
.sku-edit-advice { grid-column: 1 / -1; }
|
||||
.sku-edit-form small { display: block; color: #777; margin-bottom: 8px; }
|
||||
|
||||
/* ── 弹窗 ─────────────────────────────── */
|
||||
/* 用 hidden 属性控制显示隐藏,JS 只改这一个属性,不拼样式 */
|
||||
.modal-backdrop[hidden] { display: none; }
|
||||
|
||||
@@ -60,16 +60,47 @@
|
||||
{{end}}
|
||||
|
||||
<h3>规格({{len .SKUs}} 个{{if gt .PendingCount 0}},{{.PendingCount}} 个待人工补{{end}})</h3>
|
||||
{{$product := .}}
|
||||
{{if not .SKUs}}
|
||||
<p class="missing">这个商品在报表里没有任何 SKU。</p>
|
||||
{{else}}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>规格ID</th><th>颜色</th><th>尺码</th><th>建議</th><th>状态</th></tr></thead>
|
||||
<thead><tr><th>蝦皮 SKU ID</th><th>规格原文</th><th>颜色</th><th>尺码</th><th>建议说明</th><th>状态</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .SKUs}}
|
||||
<tr{{if .Pending}} class="row-warn"{{end}}><td>{{.SKUID}}</td><td>{{.Color}}</td><td>{{.Size}}</td><td>{{.Advice}}</td><td>{{.StatusText}}</td></tr>
|
||||
{{if .Pending}}<tr class="row-warn"><td colspan="5"><small>原文:{{.SpecRaw}}</small></td></tr>{{end}}
|
||||
<tr{{if .Pending}} class="row-warn"{{end}}>
|
||||
<td>{{if .SKUID}}{{.SKUID}}{{else}}—{{end}}</td>
|
||||
<td class="truncate" title="{{.SpecRaw}}">{{if .SpecRaw}}{{.SpecRaw}}{{else}}—{{end}}</td>
|
||||
<td>{{if .Color}}{{.Color}}{{else}}—{{end}}</td>
|
||||
<td>{{if .Size}}{{.Size}}{{else}}—{{end}}</td>
|
||||
<td>{{if .Advice}}{{.Advice}}{{else}}—{{end}}</td>
|
||||
<td>{{.StatusText}}</td>
|
||||
<td>
|
||||
<details class="sku-editor">
|
||||
<summary>编辑</summary>
|
||||
<form method="post" action="/shopee/spec/save" class="sku-edit-form">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="shopee_goods_id" value="{{$product.GoodsID}}">
|
||||
<input type="hidden" name="sku_record_id" value="{{.RecordID}}">
|
||||
<input type="hidden" name="open_id" value="{{$product.GoodsID}}">
|
||||
<input type="hidden" name="q" value="{{$.Keyword}}">
|
||||
<input type="hidden" name="search_field" value="{{$.SearchField}}">
|
||||
<input type="hidden" name="status" value="{{$.StatusFilter}}">
|
||||
<input type="hidden" name="deleted" value="{{if $.DeletedFilter}}1{{end}}">
|
||||
<input type="hidden" name="unlinked" value="{{if $.UnlinkedFilter}}1{{end}}">
|
||||
<input type="hidden" name="page" value="{{$.CurrentPage}}">
|
||||
<div class="sku-edit-grid">
|
||||
<label class="sku-edit-field"><span>颜色</span><input type="text" name="color" value="{{.Color}}" maxlength="500" required></label>
|
||||
<label class="sku-edit-field"><span>尺码</span><input type="text" name="size" value="{{.Size}}" maxlength="500" required></label>
|
||||
<label class="sku-edit-field sku-edit-advice"><span>建议说明</span><input type="text" name="advice" value="{{.Advice}}" maxlength="500"></label>
|
||||
</div>
|
||||
<small>规格原文和 SKU 身份不会修改;保存后这三个字段按人工值保护。</small>
|
||||
<div><button type="submit" class="primary">保存规格</button></div>
|
||||
</form>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -158,7 +158,7 @@
|
||||
app.js 不需要改,见工单 #41。
|
||||
|
||||
弹窗同时提供当前 PDD 关联和单商品采集入口。 */}}
|
||||
<div class="modal-backdrop" id="detail-modal" data-detail-url="{{.DetailURL}}" hidden>
|
||||
<div class="modal-backdrop" id="detail-modal" data-detail-url="{{.DetailURL}}"{{if .AutoOpenDetailID}} data-auto-open-detail-id="{{.AutoOpenDetailID}}"{{end}} hidden>
|
||||
<div class="modal" role="dialog" aria-modal="true" data-detail-slot>
|
||||
<div class="modal-body">正在加载…</div>
|
||||
</div>
|
||||
|
||||
@@ -88,8 +88,8 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
||||
### 4.1 蝦皮数据模块
|
||||
|
||||
**顶部工具条:** 导入记录按钮、批量创建 PDD 采集任务按钮、状态筛选
|
||||
(全部 / 待补规格 / 未填 PDD 链接 / 已填链接)、店铺与图片筛选、商品 ID 和店铺名称
|
||||
搜索、搜索按钮;管理员另有正常/已删除范围筛选以及删除/恢复按钮。
|
||||
(全部 / 待补规格 / 未填 PDD 链接 / 已填链接)、分类(商品 ID / 商品名称 / 店铺名)、
|
||||
关键词和搜索按钮;管理员另有回收站入口以及删除/恢复按钮。
|
||||
|
||||
`[必须]` 状态筛选是**刚需**,不是锦上添花(工单 #43):本页的主要用途是维护
|
||||
(找出需要补规格的商品、找出还没关联 PDD 链接的商品),只靠商品 ID 搜索的话,
|
||||
@@ -97,21 +97,23 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
||||
实测样本 5195 个商品里只有 6 个待补规格,只做分页要翻 260 页才能找全,等于找不到。
|
||||
界面细节和筛选条件的 SQL 见 [05 界面规范](05-ui-specification.md) §4.1。
|
||||
|
||||
**中间表格**(按 SKU 展开显示,数据来自商品表和 SKU 表联查):
|
||||
**中间表格**(一个商品一行,SKU 数据聚合显示):
|
||||
|
||||
| 列 | 说明 |
|
||||
|---|---|
|
||||
| 勾选 | 支持批量删除、批量创建 PDD 采集任务 |
|
||||
| 商品 ID | 蝦皮商品编号 |
|
||||
| 商品名称 | |
|
||||
| 颜色 / 尺码 / 建议 | 从规格原文解析,解析不出来留空并标记 |
|
||||
| PDD 链接 | 人工填写,空的要显眼 |
|
||||
| 颜色 / 尺码 / SKU / 待补 | 聚合计数,待补大于 0 时整行提示 |
|
||||
| PDD 商品 ID | 已关联时显示商品 ID,完整链接放在悬停提示和详情;空的要显眼 |
|
||||
| 采集状态 | 见 §6 |
|
||||
| 更新时间 | |
|
||||
|
||||
**双击行打开编辑弹窗**,这是**从蝦皮商品出发**填 PDD 链接、发起采集的入口:
|
||||
|
||||
- 可编辑当前 PDD 商品链接;规格表继续只读展示;
|
||||
- 可编辑当前 PDD 商品链接;规格表摘要只读,每条现有 SKU 可展开修改颜色、尺码和建议说明;
|
||||
- 人工修改只写字段级来源,不修改规格原文、规格键、真实 SKU ID 或 `is_manual`;后续
|
||||
目录导入和 SYB 低优先级补全不得覆盖人工字段,主动清空的建议说明也按人工值保护;
|
||||
- 保存链接时从完整链接解析 `goods_id`,在同一事务内复用或复活 PDD 商品档案并更新当前关联;
|
||||
- 更换成不同 `goods_id` 时必须明确勾选确认,旧规格映射保留但不会被当前关联查询误用;
|
||||
- 弹窗内提供【创建采集任务】按钮,复用 PDD 商品模块的幂等与超时重试规则;
|
||||
|
||||
@@ -470,7 +470,9 @@ MySQL schema v8 将 `shopee_skus.sku_id` 保留为系统内部记录主键(兼
|
||||
`source_observed_at` 用于限制同来源更新,`is_manual=1` 始终优先保护。
|
||||
`field_sources` 与 `field_observed_at` 是只含颜色、尺码、建议和 SKU 编码的 JSON
|
||||
来源表;`fill_missing` 补字段时分别登记,`overwrite_same_source` 只更新来源属于本次
|
||||
调用方且观测时间不旧的字段,避免不同脚本各补一部分后互相覆盖。
|
||||
调用方且观测时间不旧的字段,避免不同脚本各补一部分后互相覆盖。详情页人工修改
|
||||
颜色、尺码和建议时,把对应字段来源写为 `manual`;即使人工主动清空建议,补空策略
|
||||
也不得重新填入。人工编辑现有行不把 `is_manual` 改成 1,后者只表示人工新增整条 SKU。
|
||||
|
||||
MySQL schema v9 为 `shopee_products` 增加 `image_url` 和 `shopee_shop_name`。两者分别
|
||||
使用 `image_source/image_observed_at/image_is_manual` 与
|
||||
|
||||
@@ -182,7 +182,7 @@
|
||||
| 商品名称 | 固定约 120px,截断 + 悬停完整 |
|
||||
| 店铺 | 导入或同步得到的蝦皮店铺名称;为空显示 `—` |
|
||||
| 数据来源 | 蝦皮报表 / 顺运宝补建;不只靠颜色区分 |
|
||||
| 颜色 / 尺码 / 建议 | 解析失败的显示为 `—` 并**整行标黄**,提示需人工补 |
|
||||
| 颜色 / 尺码 / SKU / 待补 | 商品级聚合计数;待补大于 0 时**整行标黄** |
|
||||
| PDD 商品 ID | 正文显示 goods_id,悬停显示完整 URL;空的显示"**未填写**"并标红 |
|
||||
| 采集状态 | 未填链接 / 未采集 / 采集中 / 已采集 / 采集失败。**数据来自两张表**,判断方式见 [01 需求](01-requirements.md) §6.1 |
|
||||
| 更新时间 | 本地时区 |
|
||||
@@ -201,11 +201,8 @@
|
||||
│ 编辑商品 40653075144 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 商品名称 【台灣現貨隔日達】西裝外套… │ ← 只读
|
||||
│ 规格原文 黑色,M【建議40-50公斤】 │ ← 只读,永远显示
|
||||
│ │
|
||||
│ 颜色 [黑色___________] │
|
||||
│ 尺码 [M_______________] │
|
||||
│ 建议 [40-50公斤_______] │
|
||||
│ 规格表:原文 / 颜色 / 尺码 / 建议 / 状态 │ ← 摘要只读
|
||||
│ [编辑] → 颜色、尺码、建议的行内折叠表单 │
|
||||
│ │
|
||||
│ PDD 链接 [___________________________] │ ← ★
|
||||
│ 采集状态 已采集(2026-08-06 15:20) │
|
||||
@@ -218,6 +215,11 @@
|
||||
|
||||
- **创建采集任务只在这个弹窗里出现**,不要放到表格每一行或工具条,确保操作员看得到当前关联商品。
|
||||
- **规格原文只读且永远显示**。操作员靠它判断颜色尺码该怎么填。
|
||||
- 只允许编辑已有 SKU 的颜色、尺码和建议说明;颜色、尺码必填,建议可清空。真实
|
||||
Shopee SKU ID、内部记录 ID、规格键和原文不可编辑,也不在页面冒充彼此。
|
||||
- 保存后颜色、尺码和建议写入字段级 `manual` 来源并标记为已解析;目录接口的补空和
|
||||
同来源覆盖均不得覆盖这些字段。主动清空建议同样是明确的人工值。
|
||||
- 本功能不新增或删除 SKU,不修改顺运宝原始规格、既有规格映射或采购任务。
|
||||
- 保存链接时展示当前解析出的 PDD 商品 ID;新链接指向其他商品时必须勾选明确确认。
|
||||
- 无法解析 `goods_id` 的链接整体不写入;同一商品复用档案,软删除档案可复活。
|
||||
- PDD 链接为空时**采集按钮置灰**,旁边提示"请先填写 PDD 链接"。
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
`[必须]` 覆盖:
|
||||
|
||||
- **商品目录批次**:鉴权、原子写入、幂等重放、关联冲突和旧观测时间保护;
|
||||
- **upsert 不覆盖人工字段**:先填 PDD 链接,再通过目录接口导入,断言链接还在;
|
||||
- **upsert 不覆盖人工字段**:先填 PDD 链接和人工修改 SKU 颜色/尺码/建议,再分别用
|
||||
`fill_missing`、`overwrite_same_source` 导入,断言链接和人工字段(含清空建议)仍在;
|
||||
- **PDD 链接批量导入**:空工作表、正确/错误表头、两个允许域名、短链/非 PDD
|
||||
链接、空行、5000 条边界、文件内重复、新建/复用/复活分类和重复导入不覆盖采集结果;
|
||||
- 任务创建校验:商品、采集结果、有效映射、数量、人民币价格上限、客户端可见范围逐条覆盖;
|
||||
|
||||
Reference in New Issue
Block a user