feat: 支持人工修改蝦皮规格 (#215)

This commit is contained in:
chengma
2026-08-13 12:14:02 +08:00
parent 5b7d92289e
commit 4752fef54f
16 changed files with 403 additions and 34 deletions
+15 -15
View File
@@ -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)
+3
View File
@@ -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)
}
+102
View File
@@ -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
}
+122
View File
@@ -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, &timesRaw, &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), &times); 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)
}
})
}
}