feat: 重构顺运宝规格主链路 (#88)
This commit is contained in:
@@ -23,8 +23,7 @@ import (
|
|||||||
// ---------- 2. 顺运宝数据 ----------
|
// ---------- 2. 顺运宝数据 ----------
|
||||||
|
|
||||||
// SybList 渲染货运单列表页。
|
// SybList 渲染货运单列表页。
|
||||||
// 「匹配状态」是算出来的(shopee_sku_id 是否非空),不是存的字段,
|
// 「匹配状态」是根据 spec_mappings 和当前 PDD 选项算出来的,不是存的字段。
|
||||||
// 本工单(#46)不做规格匹配,这里只如实显示"有没有"。
|
|
||||||
func (h *Handler) SybList(c *gin.Context) {
|
func (h *Handler) SybList(c *gin.Context) {
|
||||||
h.renderSybList(c, c.Query("order_no"), c.Query("page"), c.Query("msg"))
|
h.renderSybList(c, c.Query("order_no"), c.Query("page"), c.Query("msg"))
|
||||||
}
|
}
|
||||||
@@ -560,17 +559,6 @@ func appendSybFormState(params url.Values, c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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, "蝦皮规格已确认,下一步按处理状态继续。")
|
|
||||||
}
|
|
||||||
|
|
||||||
// SybAssociatePdd 从货运单上下文关联 PDD 商品,实际业务规则与蝦皮详情共用。
|
// SybAssociatePdd 从货运单上下文关联 PDD 商品,实际业务规则与蝦皮详情共用。
|
||||||
func (h *Handler) SybAssociatePdd(c *gin.Context) {
|
func (h *Handler) SybAssociatePdd(c *gin.Context) {
|
||||||
goodsID, err := service.AssociateSybPdd(h.db, c.PostForm("syb_id"), c.PostForm("pdd_url"),
|
goodsID, err := service.AssociateSybPdd(h.db, c.PostForm("syb_id"), c.PostForm("pdd_url"),
|
||||||
@@ -594,8 +582,8 @@ func (h *Handler) SybCollectPdd(c *gin.Context) {
|
|||||||
|
|
||||||
// SybMatch 保存规格匹配结果。
|
// SybMatch 保存规格匹配结果。
|
||||||
//
|
//
|
||||||
// 关键:保存的是**可复用的 SKU 映射**(写 sku_mappings),
|
// 关键:保存的是**可复用的商品规格映射**(写 spec_mappings),
|
||||||
// 不是这一张订单的临时数据。下次遇到同一个蝦皮 SKU 自动带出,
|
// 不是这一张订单的临时数据。下次遇到同一个商品和规格原文自动带出,
|
||||||
// 操作员只需确认。见 docs/admin/03-data-model.md §5。
|
// 操作员只需确认。见 docs/admin/03-data-model.md §5。
|
||||||
func (h *Handler) SybMatch(c *gin.Context) {
|
func (h *Handler) SybMatch(c *gin.Context) {
|
||||||
actor := currentUser(c)
|
actor := currentUser(c)
|
||||||
@@ -607,7 +595,7 @@ func (h *Handler) SybMatch(c *gin.Context) {
|
|||||||
h.sybRedirect(c, "规格映射未保存:"+err.Error())
|
h.sybRedirect(c, "规格映射未保存:"+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.sybRedirect(c, "规格映射已保存;相同蝦皮规格和 PDD 商品会自动复用。")
|
h.sybRedirect(c, "规格映射已保存;相同蝦皮商品、顺运宝规格和 PDD 商品会自动复用。")
|
||||||
}
|
}
|
||||||
|
|
||||||
// SybCreateTask 由勾选的货运单创建采购任务。
|
// SybCreateTask 由勾选的货运单创建采购任务。
|
||||||
@@ -615,7 +603,7 @@ func (h *Handler) SybMatch(c *gin.Context) {
|
|||||||
// 六条校验缺一不可,见 docs/admin/01-requirements.md §5:
|
// 六条校验缺一不可,见 docs/admin/01-requirements.md §5:
|
||||||
// 1. 已填 PDD 链接
|
// 1. 已填 PDD 链接
|
||||||
// 2. 已采集成功(pdd_data 非空)
|
// 2. 已采集成功(pdd_data 非空)
|
||||||
// 3. 已有 SKU 映射
|
// 3. 已有商品规格映射
|
||||||
// 4. 数量 > 0
|
// 4. 数量 > 0
|
||||||
// 5. **价格上限已填且 > 0**(Client 的价格保护,没有它会拒绝执行)
|
// 5. **价格上限已填且 > 0**(Client 的价格保护,没有它会拒绝执行)
|
||||||
// 6. 已选择分配的客户端
|
// 6. 已选择分配的客户端
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
|
|||||||
pages.GET("/syb/captcha", h.SybCaptcha) // 登录弹窗里的验证码图片
|
pages.GET("/syb/captcha", h.SybCaptcha) // 登录弹窗里的验证码图片
|
||||||
pages.POST("/syb/sync", h.SybSync)
|
pages.POST("/syb/sync", h.SybSync)
|
||||||
pages.POST("/syb/login-and-sync", h.SybLoginAndSync)
|
pages.POST("/syb/login-and-sync", h.SybLoginAndSync)
|
||||||
pages.POST("/syb/select-sku", h.SybSelectSKU)
|
|
||||||
pages.POST("/syb/associate-pdd", h.SybAssociatePdd)
|
pages.POST("/syb/associate-pdd", h.SybAssociatePdd)
|
||||||
pages.POST("/syb/collect-pdd", h.SybCollectPdd)
|
pages.POST("/syb/collect-pdd", h.SybCollectPdd)
|
||||||
pages.POST("/syb/match", h.SybMatch)
|
pages.POST("/syb/match", h.SybMatch)
|
||||||
|
|||||||
+25
-1
@@ -58,7 +58,7 @@ func TestMainPagesReturnOK(t *testing.T) {
|
|||||||
if err := service.SetupInitialAdmin(db, "admin", "test-password", "test-password", time.Now()); err != nil {
|
if err := service.SetupInitialAdmin(db, "admin", "test-password", "test-password", time.Now()); err != nil {
|
||||||
t.Fatalf("准备测试管理员失败: %v", err)
|
t.Fatalf("准备测试管理员失败: %v", err)
|
||||||
}
|
}
|
||||||
token, _, _, err := service.Login(db, "admin", "test-password", time.Now())
|
token, adminUser, _, err := service.Login(db, "admin", "test-password", time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("准备测试登录 Session 失败: %v", err)
|
t.Fatalf("准备测试登录 Session 失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -84,6 +84,30 @@ func TestMainPagesReturnOK(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := service.CreatePurchaser(db, adminUser, "buyer", "buyer-password", "buyer-password", time.Now()); err != nil {
|
||||||
|
t.Fatalf("准备测试采购员失败: %v", err)
|
||||||
|
}
|
||||||
|
buyerToken, _, _, err := service.Login(db, "buyer", "buyer-password", time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("准备采购员 Session 失败: %v", err)
|
||||||
|
}
|
||||||
|
for _, path := range []string{"/shopee", "/pdd", "/syb", "/tasks", "/clients"} {
|
||||||
|
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: buyerToken})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("采购员 GET %s = %d,期望 200", path, response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usersRequest := httptest.NewRequest(http.MethodGet, "/users", nil)
|
||||||
|
usersRequest.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: buyerToken})
|
||||||
|
usersResponse := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(usersResponse, usersRequest)
|
||||||
|
if usersResponse.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("采购员 GET /users = %d,期望 403", usersResponse.Code)
|
||||||
|
}
|
||||||
|
|
||||||
sybRequest := httptest.NewRequest(http.MethodGet, "/syb", nil)
|
sybRequest := httptest.NewRequest(http.MethodGet, "/syb", nil)
|
||||||
addAuth(sybRequest)
|
addAuth(sybRequest)
|
||||||
sybResponse := httptest.NewRecorder()
|
sybResponse := httptest.NewRecorder()
|
||||||
|
|||||||
+16
-15
@@ -168,6 +168,7 @@ type ShopeeProduct struct {
|
|||||||
Title string
|
Title string
|
||||||
ShopeeStatus string
|
ShopeeStatus string
|
||||||
MainSKUCode string
|
MainSKUCode string
|
||||||
|
Source string // report=蝦皮报表完整行;syb=顺运宝同步补建的商品骨架
|
||||||
PddGoodsURL string
|
PddGoodsURL string
|
||||||
PddGoodsID string // 指向 PddProduct.GoodsID,为空表示还没填链接
|
PddGoodsID string // 指向 PddProduct.GoodsID,为空表示还没填链接
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
@@ -201,17 +202,16 @@ type ShopeeSKU struct {
|
|||||||
// PriceTwdCent 是**台币分**,跟采购任务的人民币价格上限没有换算关系,
|
// PriceTwdCent 是**台币分**,跟采购任务的人民币价格上限没有换算关系,
|
||||||
// 不要互相赋值,见 docs/admin/01-requirements.md §7。
|
// 不要互相赋值,见 docs/admin/01-requirements.md §7。
|
||||||
//
|
//
|
||||||
// `[必须]` ShopeeSKUID 是蝦皮规格识别结果(人工确认或自动识别产生),
|
// ShopeeSKUID 是 #88 之前的蝦皮规格识别结果。数据库列因只追加迁移保留,
|
||||||
// 它不代表已经完成蝦皮 SKU 到 PDD SKU 的采购规格映射。
|
// 新同步和采购主链路均不再读写它。
|
||||||
// 顺运宝同步**绝不能覆盖它**——顺运宝根本没有这个值,见工单 #46、
|
|
||||||
// docs/admin/08-顺运宝接口.md §6.2。
|
|
||||||
type SybOrder struct {
|
type SybOrder struct {
|
||||||
SybID string
|
SybID string
|
||||||
OrderNo string
|
OrderNo string
|
||||||
Title string
|
Title string
|
||||||
ProductSpec string // 规格原文,顺运宝 productSpec,原样保留,对应 shopee_skus.spec_raw
|
ProductSpec string // 规格原文,顺运宝 productSpec,原样保留,对应 shopee_skus.spec_raw
|
||||||
|
SpecKey string // ProductSpec 的稳定身份键;空规格保持 SQL NULL,读出时为空字符串
|
||||||
ShopeeGoodsID string
|
ShopeeGoodsID string
|
||||||
ShopeeSKUID string // 蝦皮规格识别结果;顺运宝同步永远不覆盖这一列,见上面的注释
|
ShopeeSKUID string // 历史兼容字段;新主链路不使用
|
||||||
Quantity int
|
Quantity int
|
||||||
PriceTwdCent int64
|
PriceTwdCent int64
|
||||||
ImageURL string
|
ImageURL string
|
||||||
@@ -250,14 +250,14 @@ type SybSyncRun struct {
|
|||||||
FinishedAt string
|
FinishedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
// SKUMapping 是「蝦皮的这个规格 = 拼多多的那个规格」。
|
// SpecMapping 是「这个蝦皮商品的顺运宝规格 = 拼多多的那个规格」。
|
||||||
//
|
//
|
||||||
// # 为什么主键要带上 PddGoodsID
|
// # 为什么主键要带上 PddGoodsID
|
||||||
//
|
//
|
||||||
// PDD 商品下架换代很频繁。假设蝦皮商品 X 原来对应 PDD 商品 A,
|
// PDD 商品下架换代很频繁。假设蝦皮商品 X 原来对应 PDD 商品 A,
|
||||||
// 操作员匹配好了"黑色/M → 黑色/M码";后来 A 下架,换成了 B。
|
// 操作员匹配好了"黑色/M → 黑色/M码";后来 A 下架,换成了 B。
|
||||||
//
|
//
|
||||||
// 如果映射只按 ShopeeSKUID 存,那条旧映射还在,但它描述的是 **A 的规格**:
|
// 如果映射不带 PddGoodsID,那条旧映射还在,但它描述的是 **A 的规格**:
|
||||||
//
|
//
|
||||||
// - 运气好:B 没有"黑色/M码",建任务时找不到会报错,还算安全;
|
// - 运气好:B 没有"黑色/M码",建任务时找不到会报错,还算安全;
|
||||||
// - 运气坏:B 恰好也有"黑色/M码",但完全是另一件衣服
|
// - 运气坏:B 恰好也有"黑色/M码",但完全是另一件衣服
|
||||||
@@ -268,14 +268,15 @@ type SybSyncRun struct {
|
|||||||
// 不需要在换商品时记得去删旧数据——靠查询条件天然隔离,忘不了。
|
// 不需要在换商品时记得去删旧数据——靠查询条件天然隔离,忘不了。
|
||||||
//
|
//
|
||||||
// 附带好处:A 的映射还留着。A 补货换回去时,之前的匹配成果直接复用。
|
// 附带好处:A 的映射还留着。A 补货换回去时,之前的匹配成果直接复用。
|
||||||
type SKUMapping struct {
|
type SpecMapping struct {
|
||||||
ShopeeSKUID string
|
ShopeeGoodsID string
|
||||||
PddGoodsID string // 这条映射属于哪个 PDD 商品
|
SpecKey string // 由 spec.SpecKey 生成,存和查必须使用同一实现
|
||||||
PddOptionKey string // 规范化后的组合键,见 service.OptionKey
|
SpecRaw string // 保存当时的顺运宝原文,供人工核对
|
||||||
PddOptions string // 原始 options 对象 JSON,显示用
|
PddGoodsID string // 这条映射属于哪个 PDD 商品
|
||||||
GoodsID string // 蝦皮商品 ID,方便按商品批量查
|
PddOptionKey string // 规范化后的组合键,见 service.OptionKey
|
||||||
MappedAt string
|
PddOptions string // 原始 options 对象 JSON,显示用
|
||||||
MappedBy string
|
MappedAt string
|
||||||
|
MappedBy string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 任务 ----------
|
// ---------- 任务 ----------
|
||||||
|
|||||||
+19
-19
@@ -8,18 +8,18 @@ import (
|
|||||||
"cmautobuy/admin/model"
|
"cmautobuy/admin/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetSKUMapping 读取某个蝦皮 SKU 在指定 PDD 商品下的映射。
|
// GetSpecMapping 读取某个蝦皮商品的顺运宝规格在指定 PDD 商品下的映射。
|
||||||
func GetSKUMapping(q Execer, shopeeSKUID, pddGoodsID string) (*model.SKUMapping, error) {
|
func GetSpecMapping(q Execer, shopeeGoodsID, specKey, pddGoodsID string) (*model.SpecMapping, error) {
|
||||||
var m model.SKUMapping
|
var m model.SpecMapping
|
||||||
var mappedBy sql.NullString
|
var mappedBy sql.NullString
|
||||||
err := q.QueryRow(`
|
err := q.QueryRow(`
|
||||||
SELECT shopee_sku_id, pdd_goods_id, pdd_option_key, pdd_options,
|
SELECT shopee_goods_id, spec_key, pdd_goods_id, pdd_option_key,
|
||||||
goods_id, mapped_at, mapped_by
|
pdd_options, spec_raw, mapped_at, mapped_by
|
||||||
FROM sku_mappings
|
FROM spec_mappings
|
||||||
WHERE shopee_sku_id = ? AND pdd_goods_id = ?`,
|
WHERE shopee_goods_id = ? AND spec_key = ? AND pdd_goods_id = ?`,
|
||||||
shopeeSKUID, pddGoodsID).Scan(
|
shopeeGoodsID, specKey, pddGoodsID).Scan(
|
||||||
&m.ShopeeSKUID, &m.PddGoodsID, &m.PddOptionKey, &m.PddOptions,
|
&m.ShopeeGoodsID, &m.SpecKey, &m.PddGoodsID, &m.PddOptionKey,
|
||||||
&m.GoodsID, &m.MappedAt, &mappedBy)
|
&m.PddOptions, &m.SpecRaw, &m.MappedAt, &mappedBy)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -30,21 +30,21 @@ func GetSKUMapping(q Execer, shopeeSKUID, pddGoodsID string) (*model.SKUMapping,
|
|||||||
return &m, nil
|
return &m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpsertSKUMapping 保存可复用规格映射,唯一键包含当前 PDD 商品。
|
// UpsertSpecMapping 保存可复用规格映射,唯一键包含当前 PDD 商品。
|
||||||
func UpsertSKUMapping(q Execer, m model.SKUMapping) error {
|
func UpsertSpecMapping(q Execer, m model.SpecMapping) error {
|
||||||
_, err := q.Exec(`
|
_, err := q.Exec(`
|
||||||
INSERT INTO sku_mappings
|
INSERT INTO spec_mappings
|
||||||
(shopee_sku_id, pdd_goods_id, pdd_option_key, pdd_options,
|
(shopee_goods_id, spec_key, pdd_goods_id, pdd_option_key,
|
||||||
goods_id, mapped_at, mapped_by)
|
pdd_options, spec_raw, mapped_at, mapped_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
pdd_option_key = VALUES(pdd_option_key),
|
pdd_option_key = VALUES(pdd_option_key),
|
||||||
pdd_options = VALUES(pdd_options),
|
pdd_options = VALUES(pdd_options),
|
||||||
goods_id = VALUES(goods_id),
|
spec_raw = VALUES(spec_raw),
|
||||||
mapped_at = VALUES(mapped_at),
|
mapped_at = VALUES(mapped_at),
|
||||||
mapped_by = VALUES(mapped_by)`,
|
mapped_by = VALUES(mapped_by)`,
|
||||||
m.ShopeeSKUID, m.PddGoodsID, m.PddOptionKey, m.PddOptions,
|
m.ShopeeGoodsID, m.SpecKey, m.PddGoodsID, m.PddOptionKey,
|
||||||
m.GoodsID, m.MappedAt, nullableText(m.MappedBy))
|
m.PddOptions, m.SpecRaw, m.MappedAt, nullableText(m.MappedBy))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("保存规格映射失败: %w", err)
|
return fmt.Errorf("保存规格映射失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,9 +15,11 @@ import (
|
|||||||
"github.com/go-sql-driver/mysql"
|
"github.com/go-sql-driver/mysql"
|
||||||
|
|
||||||
"cmautobuy/admin/config"
|
"cmautobuy/admin/config"
|
||||||
|
"cmautobuy/admin/model"
|
||||||
|
"cmautobuy/admin/spec"
|
||||||
)
|
)
|
||||||
|
|
||||||
const mysqlSchemaVersion = 2
|
const mysqlSchemaVersion = 3
|
||||||
|
|
||||||
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
||||||
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
||||||
@@ -370,6 +373,20 @@ var mysqlSchemaV2 = []string{
|
|||||||
ON DUPLICATE KEY UPDATE id = VALUES(id)`,
|
ON DUPLICATE KEY UPDATE id = VALUES(id)`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mysqlSchemaV3SpecMappings = `CREATE TABLE IF NOT EXISTS spec_mappings (
|
||||||
|
shopee_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
spec_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
pdd_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
pdd_option_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
pdd_options LONGTEXT NOT NULL,
|
||||||
|
spec_raw TEXT NOT NULL,
|
||||||
|
mapped_at VARCHAR(35) NOT NULL,
|
||||||
|
mapped_by VARCHAR(191),
|
||||||
|
PRIMARY KEY (shopee_goods_id, spec_key, pdd_goods_id),
|
||||||
|
KEY idx_spec_mappings_goods (shopee_goods_id),
|
||||||
|
KEY idx_spec_mappings_pdd (pdd_goods_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`
|
||||||
|
|
||||||
// MigrateMySQL 建立或升级 MySQL schema。生产迁移只能在这里追加新版本。
|
// MigrateMySQL 建立或升级 MySQL schema。生产迁移只能在这里追加新版本。
|
||||||
func MigrateMySQL(db *sql.DB) error {
|
func MigrateMySQL(db *sql.DB) error {
|
||||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
@@ -406,21 +423,274 @@ func MigrateMySQL(db *sql.DB) error {
|
|||||||
return fmt.Errorf("执行 MySQL schema v2 第 %d 条失败: %w", i+1, err)
|
return fmt.Errorf("执行 MySQL schema v2 第 %d 条失败: %w", i+1, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := CheckMySQLSchema(db); err != nil {
|
mysqlRequiredTablesV2 := append(append([]string{}, requiredTables...), "admin_initialization_lock")
|
||||||
|
if err := checkMySQLSchema(db, mysqlRequiredTablesV2); err != nil {
|
||||||
return fmt.Errorf("MySQL schema v2 自检失败,未记录版本: %w", err)
|
return fmt.Errorf("MySQL schema v2 自检失败,未记录版本: %w", err)
|
||||||
}
|
}
|
||||||
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`,
|
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`,
|
||||||
2, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
2, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||||
return fmt.Errorf("记录 MySQL schema v2 失败: %w", err)
|
return fmt.Errorf("记录 MySQL schema v2 失败: %w", err)
|
||||||
}
|
}
|
||||||
|
current = 2
|
||||||
|
}
|
||||||
|
if current < 3 {
|
||||||
|
if err := migrateMySQLV3(db); err != nil {
|
||||||
|
return fmt.Errorf("执行 MySQL schema v3 失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := CheckMySQLSchema(db); err != nil {
|
||||||
|
return fmt.Errorf("MySQL schema v3 自检失败,未记录版本: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`,
|
||||||
|
3, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||||
|
return fmt.Errorf("记录 MySQL schema v3 失败: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return CheckMySQLSchema(db)
|
return CheckMySQLSchema(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckMySQLSchema 确认所有业务表和关键追加列存在。
|
// migrateMySQLV3 把采购规格身份从蝦皮 SKU 改为顺运宝商品规格原文。
|
||||||
|
// 每一步都可重放:DDL 已提交但版本尚未记录时,再启动仍会收敛。
|
||||||
|
func migrateMySQLV3(db *sql.DB) error {
|
||||||
|
if err := ensureMySQLV3Columns(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(mysqlSchemaV3SpecMappings); err != nil {
|
||||||
|
return fmt.Errorf("建立 spec_mappings 失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := backfillSybSpecKeys(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureSybShopeeSkeletons(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return convertLegacySKUMappings(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureMySQLV3Columns(db *sql.DB) error {
|
||||||
|
exists, err := mysqlColumnExists(db, "syb_orders", "spec_key")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
if _, err := db.Exec(`ALTER TABLE syb_orders ADD COLUMN spec_key VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER product_spec`); err != nil {
|
||||||
|
return fmt.Errorf("增加 syb_orders.spec_key 失败: %w", err)
|
||||||
|
}
|
||||||
|
} else if _, err := db.Exec(`ALTER TABLE syb_orders MODIFY COLUMN spec_key VARCHAR(191) COLLATE utf8mb4_bin NULL`); err != nil {
|
||||||
|
return fmt.Errorf("校正 syb_orders.spec_key 结构失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err = mysqlColumnExists(db, "shopee_products", "source")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
if _, err := db.Exec(`ALTER TABLE shopee_products ADD COLUMN source VARCHAR(16) COLLATE utf8mb4_bin NOT NULL DEFAULT 'report' AFTER main_sku_code`); err != nil {
|
||||||
|
return fmt.Errorf("增加 shopee_products.source 失败: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 兼容“列已加但还是可空”的 DDL 中断点。先回填旧行,
|
||||||
|
// 再收紧 NOT NULL,否则带 NULL 的存量数据会让 ALTER 失败。
|
||||||
|
if _, err := db.Exec(`UPDATE shopee_products SET source='report' WHERE source IS NULL OR source=''`); err != nil {
|
||||||
|
return fmt.Errorf("回填 shopee_products.source 失败: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`ALTER TABLE shopee_products MODIFY COLUMN source VARCHAR(16) COLLATE utf8mb4_bin NOT NULL DEFAULT 'report'`); err != nil {
|
||||||
|
return fmt.Errorf("校正 shopee_products.source 结构失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
constraintExists, err := mysqlConstraintExists(db, "shopee_products", "chk_shopee_products_source")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !constraintExists {
|
||||||
|
if _, err := db.Exec(`ALTER TABLE shopee_products ADD CONSTRAINT chk_shopee_products_source CHECK (source IN ('report','syb'))`); err != nil {
|
||||||
|
return fmt.Errorf("增加 shopee_products.source CHECK 失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mysqlColumnExists(db *sql.DB, table, column string) (bool, error) {
|
||||||
|
var count int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM information_schema.columns
|
||||||
|
WHERE table_schema=DATABASE() AND table_name=? AND column_name=?`, table, column).Scan(&count); err != nil {
|
||||||
|
return false, fmt.Errorf("检查 MySQL 数据表 %s.%s 失败: %w", table, column, err)
|
||||||
|
}
|
||||||
|
return count == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mysqlConstraintExists(db *sql.DB, table, constraint string) (bool, error) {
|
||||||
|
var count int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM information_schema.table_constraints
|
||||||
|
WHERE constraint_schema=DATABASE() AND table_name=? AND constraint_name=?`, table, constraint).Scan(&count); err != nil {
|
||||||
|
return false, fmt.Errorf("检查 MySQL 约束 %s.%s 失败: %w", table, constraint, err)
|
||||||
|
}
|
||||||
|
return count == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func backfillSybSpecKeys(db *sql.DB) error {
|
||||||
|
const batchSize = 500
|
||||||
|
cursor := ""
|
||||||
|
for {
|
||||||
|
rows, err := db.Query(`SELECT syb_id, product_spec FROM syb_orders
|
||||||
|
WHERE syb_id > ? AND spec_key IS NULL AND product_spec IS NOT NULL
|
||||||
|
AND TRIM(product_spec) <> '' ORDER BY syb_id LIMIT ?`, cursor, batchSize)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("读取待回填顺运宝规格失败: %w", err)
|
||||||
|
}
|
||||||
|
type item struct{ id, raw, key string }
|
||||||
|
items := make([]item, 0, batchSize)
|
||||||
|
for rows.Next() {
|
||||||
|
var value item
|
||||||
|
if err := rows.Scan(&value.id, &value.raw); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return fmt.Errorf("读取待回填顺运宝规格失败: %w", err)
|
||||||
|
}
|
||||||
|
value.key, err = spec.SpecKey(value.raw)
|
||||||
|
if err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return fmt.Errorf("顺运宝明细 %s 的规格不能生成身份键: %w", value.id, err)
|
||||||
|
}
|
||||||
|
items = append(items, value)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return fmt.Errorf("关闭顺运宝规格回填结果失败: %w", err)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("开始顺运宝规格回填事务失败: %w", err)
|
||||||
|
}
|
||||||
|
for _, value := range items {
|
||||||
|
if _, err := tx.Exec(`UPDATE syb_orders SET spec_key=? WHERE syb_id=? AND spec_key IS NULL`, value.key, value.id); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("回填顺运宝明细 %s 的规格键失败: %w", value.id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("提交顺运宝规格回填失败: %w", err)
|
||||||
|
}
|
||||||
|
cursor = items[len(items)-1].id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureSybShopeeSkeletons(db *sql.DB) error {
|
||||||
|
now := model.NowISO()
|
||||||
|
_, err := db.Exec(`INSERT INTO shopee_products
|
||||||
|
(goods_id, title, source, created_at, updated_at)
|
||||||
|
SELECT shopee_goods_id, MAX(COALESCE(title,'')), 'syb', ?, ?
|
||||||
|
FROM syb_orders
|
||||||
|
WHERE shopee_goods_id IS NOT NULL AND shopee_goods_id <> ''
|
||||||
|
GROUP BY shopee_goods_id
|
||||||
|
ON DUPLICATE KEY UPDATE goods_id=VALUES(goods_id)`, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("按顺运宝数据补建蝦皮商品骨架失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertLegacySKUMappings(db *sql.DB) error {
|
||||||
|
exists, err := mysqlTableExists(db, "sku_mappings")
|
||||||
|
if err != nil || !exists {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS sku_mappings_v3_backup LIKE sku_mappings`); err != nil {
|
||||||
|
return fmt.Errorf("建立旧规格映射备份表失败: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT IGNORE INTO sku_mappings_v3_backup SELECT * FROM sku_mappings`); err != nil {
|
||||||
|
return fmt.Errorf("备份旧规格映射失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Query(`SELECT m.shopee_sku_id, m.pdd_goods_id, m.pdd_option_key,
|
||||||
|
m.pdd_options, m.mapped_at, m.mapped_by, sk.goods_id, sk.spec_raw
|
||||||
|
FROM sku_mappings m LEFT JOIN shopee_skus sk ON sk.sku_id=m.shopee_sku_id`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("读取旧规格映射失败: %w", err)
|
||||||
|
}
|
||||||
|
type legacy struct {
|
||||||
|
skuID, pddID, optionKey, options, mappedAt string
|
||||||
|
mappedBy, goodsID, raw sql.NullString
|
||||||
|
}
|
||||||
|
var records []legacy
|
||||||
|
for rows.Next() {
|
||||||
|
var value legacy
|
||||||
|
if err := rows.Scan(&value.skuID, &value.pddID, &value.optionKey, &value.options,
|
||||||
|
&value.mappedAt, &value.mappedBy, &value.goodsID, &value.raw); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return fmt.Errorf("读取旧规格映射失败: %w", err)
|
||||||
|
}
|
||||||
|
records = append(records, value)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return fmt.Errorf("关闭旧规格映射结果失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
matched, noCurrentMatch, unable := 0, 0, 0
|
||||||
|
for _, value := range records {
|
||||||
|
if !value.goodsID.Valid || !value.raw.Valid {
|
||||||
|
unable++
|
||||||
|
log.Printf("mysql_v3_mapping_unconvertible shopee_sku_id=%s pdd_goods_id=%s", value.skuID, value.pddID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, err := spec.SpecKey(value.raw.String)
|
||||||
|
if err != nil {
|
||||||
|
unable++
|
||||||
|
log.Printf("mysql_v3_mapping_unconvertible shopee_sku_id=%s pdd_goods_id=%s reason=invalid_spec_key", value.skuID, value.pddID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := UpsertSpecMapping(db, model.SpecMapping{
|
||||||
|
ShopeeGoodsID: value.goodsID.String, SpecKey: key, SpecRaw: value.raw.String,
|
||||||
|
PddGoodsID: value.pddID, PddOptionKey: value.optionKey, PddOptions: value.options,
|
||||||
|
MappedAt: value.mappedAt, MappedBy: value.mappedBy.String,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("转换旧规格映射 %s 失败: %w", value.skuID, err)
|
||||||
|
}
|
||||||
|
var hit int
|
||||||
|
if err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM syb_orders WHERE shopee_goods_id=? AND spec_key=?)`,
|
||||||
|
value.goodsID.String, key).Scan(&hit); err != nil {
|
||||||
|
return fmt.Errorf("验证旧规格映射 %s 命中失败: %w", value.skuID, err)
|
||||||
|
}
|
||||||
|
if hit == 1 {
|
||||||
|
matched++
|
||||||
|
} else {
|
||||||
|
noCurrentMatch++
|
||||||
|
log.Printf("mysql_v3_mapping_no_current_hit shopee_sku_id=%s pdd_goods_id=%s", value.skuID, value.pddID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matched+noCurrentMatch+unable != len(records) {
|
||||||
|
return fmt.Errorf("旧规格映射迁移计数不守恒: 总数=%d 命中=%d 无命中=%d 无法转换=%d",
|
||||||
|
len(records), matched, noCurrentMatch, unable)
|
||||||
|
}
|
||||||
|
log.Printf("mysql_v3_mapping_summary total=%d matched=%d no_current_hit=%d unconvertible=%d backup_table=sku_mappings_v3_backup",
|
||||||
|
len(records), matched, noCurrentMatch, unable)
|
||||||
|
if _, err := db.Exec(`DROP TABLE sku_mappings`); err != nil {
|
||||||
|
return fmt.Errorf("删除已备份的旧规格映射表失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mysqlTableExists(db *sql.DB, table string) (bool, error) {
|
||||||
|
var count int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM information_schema.tables
|
||||||
|
WHERE table_schema=DATABASE() AND table_name=? AND table_type='BASE TABLE'`, table).Scan(&count); err != nil {
|
||||||
|
return false, fmt.Errorf("检查 MySQL 数据表 %s 失败: %w", table, err)
|
||||||
|
}
|
||||||
|
return count == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckMySQLSchema 确认所有业务表、关键追加列和采购身份结构完整。
|
||||||
func CheckMySQLSchema(db *sql.DB) error {
|
func CheckMySQLSchema(db *sql.DB) error {
|
||||||
mysqlRequiredTables := append(append([]string{}, requiredTables...), "admin_initialization_lock")
|
mysqlRequiredTables := []string{
|
||||||
return checkMySQLSchema(db, mysqlRequiredTables)
|
"shopee_products", "shopee_skus", "pdd_products", "syb_orders", "spec_mappings",
|
||||||
|
"tasks", "clients", "idempotency_keys", "task_claims", "syb_session", "syb_sync_state",
|
||||||
|
"users", "web_sessions", "client_user_assignments", "syb_sync_runs", "admin_initialization_lock",
|
||||||
|
}
|
||||||
|
if err := checkMySQLSchema(db, mysqlRequiredTables); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return checkMySQLV3Shape(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkMySQLSchema(db *sql.DB, tables []string) error {
|
func checkMySQLSchema(db *sql.DB, tables []string) error {
|
||||||
@@ -465,3 +735,76 @@ func checkMySQLSchema(db *sql.DB, tables []string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkMySQLV3Shape(db *sql.DB) error {
|
||||||
|
rows, err := db.Query(`SELECT column_name FROM information_schema.key_column_usage
|
||||||
|
WHERE table_schema=DATABASE() AND table_name='spec_mappings'
|
||||||
|
AND constraint_name='PRIMARY' ORDER BY ordinal_position`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("检查 spec_mappings 主键失败: %w", err)
|
||||||
|
}
|
||||||
|
var primary []string
|
||||||
|
for rows.Next() {
|
||||||
|
var column string
|
||||||
|
if err := rows.Scan(&column); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return fmt.Errorf("检查 spec_mappings 主键失败: %w", err)
|
||||||
|
}
|
||||||
|
primary = append(primary, column)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if strings.Join(primary, ",") != "shopee_goods_id,spec_key,pdd_goods_id" {
|
||||||
|
return fmt.Errorf("spec_mappings 主键不正确,实际为 (%s)", strings.Join(primary, ","))
|
||||||
|
}
|
||||||
|
for _, column := range []string{"shopee_goods_id", "spec_key", "pdd_goods_id"} {
|
||||||
|
if err := checkMySQLVarcharColumn(db, "spec_mappings", column, 191, false, "utf8mb4_bin", ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := checkMySQLVarcharColumn(db, "syb_orders", "spec_key", 191, true, "utf8mb4_bin", ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := checkMySQLVarcharColumn(db, "shopee_products", "source", 16, false, "utf8mb4_bin", "report"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var enforced, checkClause string
|
||||||
|
if err := db.QueryRow(`SELECT tc.enforced, cc.check_clause
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.check_constraints cc
|
||||||
|
ON cc.constraint_schema=tc.constraint_schema AND cc.constraint_name=tc.constraint_name
|
||||||
|
WHERE tc.constraint_schema=DATABASE() AND tc.table_name='shopee_products'
|
||||||
|
AND tc.constraint_name='chk_shopee_products_source' AND tc.constraint_type='CHECK'`).Scan(&enforced, &checkClause); err != nil {
|
||||||
|
return fmt.Errorf("shopee_products.source CHECK 缺失或不可读: %w", err)
|
||||||
|
}
|
||||||
|
if enforced != "YES" {
|
||||||
|
return fmt.Errorf("shopee_products.source CHECK 未启用")
|
||||||
|
}
|
||||||
|
normalizedClause := strings.NewReplacer("`", "", " ", "", "(", "", ")", "", "_utf8mb4", "", `\`, "").Replace(strings.ToLower(checkClause))
|
||||||
|
if !strings.Contains(normalizedClause, "sourcein'report','syb'") {
|
||||||
|
return fmt.Errorf("shopee_products.source CHECK 表达式不正确")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkMySQLVarcharColumn(db *sql.DB, table, column string, length int64, nullable bool, collation, defaultValue string) error {
|
||||||
|
var dataType, isNullable string
|
||||||
|
var actualLength sql.NullInt64
|
||||||
|
var actualCollation, actualDefault sql.NullString
|
||||||
|
if err := db.QueryRow(`SELECT data_type, character_maximum_length, is_nullable, collation_name, column_default
|
||||||
|
FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? AND column_name=?`,
|
||||||
|
table, column).Scan(&dataType, &actualLength, &isNullable, &actualCollation, &actualDefault); err != nil {
|
||||||
|
return fmt.Errorf("检查 MySQL 列 %s.%s 结构失败: %w", table, column, err)
|
||||||
|
}
|
||||||
|
wantNullable := "NO"
|
||||||
|
if nullable {
|
||||||
|
wantNullable = "YES"
|
||||||
|
}
|
||||||
|
if dataType != "varchar" || !actualLength.Valid || actualLength.Int64 != length ||
|
||||||
|
isNullable != wantNullable || actualCollation.String != collation {
|
||||||
|
return fmt.Errorf("MySQL 列 %s.%s 结构不正确", table, column)
|
||||||
|
}
|
||||||
|
if defaultValue != "" && (!actualDefault.Valid || actualDefault.String != defaultValue) {
|
||||||
|
return fmt.Errorf("MySQL 列 %s.%s 默认值不正确", table, column)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"cmautobuy/admin/config"
|
"cmautobuy/admin/config"
|
||||||
|
"cmautobuy/admin/spec"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestMySQLMigrate_真实MySQL8 只在显式提供隔离测试库时运行。
|
// TestMySQLMigrate_真实MySQL8 只在显式提供隔离测试库时运行。
|
||||||
@@ -46,6 +47,180 @@ func TestMySQLMigrate_真实MySQL8(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMySQLMigrate_V2升级V3并转换真实主链路(t *testing.T) {
|
||||||
|
db := openMySQLMigrationTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
cleanMySQLTestSchema(t, db)
|
||||||
|
defer cleanMySQLTestSchema(t, db)
|
||||||
|
prepareMySQLV2(t, db)
|
||||||
|
now := "2026-08-10T03:00:00Z"
|
||||||
|
mustExec(t, db, `INSERT INTO shopee_products (goods_id,title,created_at,updated_at) VALUES ('REPORT-1','报表商品',?,?)`, now, now)
|
||||||
|
mustExec(t, db, `INSERT INTO shopee_skus (sku_id,goods_id,spec_raw,created_at,updated_at) VALUES ('SKU-1','REPORT-1','黑色, M',?,?)`, now, now)
|
||||||
|
mustExec(t, db, `INSERT INTO sku_mappings (shopee_sku_id,pdd_goods_id,pdd_option_key,pdd_options,goods_id,mapped_at) VALUES ('SKU-1','PDD-1','color=黑色&size=M','color=黑色&size=M','REPORT-1',?)`, now)
|
||||||
|
mustExec(t, db, `INSERT INTO syb_orders (syb_id,order_no,title,shopee_goods_id,product_spec,quantity,syb_data,created_at,updated_at) VALUES
|
||||||
|
('SYB-1','ORDER-1','报表商品','REPORT-1',' 黑色, M ',1,'{}',?,?),
|
||||||
|
('SYB-2','ORDER-2','骨架商品','SKELETON-1','白色,L',1,'{}',?,?),
|
||||||
|
('SYB-3','ORDER-3','空规格','EMPTY-1',NULL,1,'{}',?,?)`, now, now, now, now, now, now)
|
||||||
|
|
||||||
|
if err := MigrateMySQL(db); err != nil {
|
||||||
|
t.Fatalf("v2→v3 失败: %v", err)
|
||||||
|
}
|
||||||
|
var key string
|
||||||
|
if err := db.QueryRow(`SELECT spec_key FROM syb_orders WHERE syb_id='SYB-1'`).Scan(&key); err != nil || key != "黑色, M" {
|
||||||
|
t.Fatalf("规格回填=%q err=%v", key, err)
|
||||||
|
}
|
||||||
|
var empty sql.NullString
|
||||||
|
if err := db.QueryRow(`SELECT spec_key FROM syb_orders WHERE syb_id='SYB-3'`).Scan(&empty); err != nil || empty.Valid {
|
||||||
|
t.Fatalf("空规格必须保持 NULL: %+v err=%v", empty, err)
|
||||||
|
}
|
||||||
|
var source string
|
||||||
|
if err := db.QueryRow(`SELECT source FROM shopee_products WHERE goods_id='SKELETON-1'`).Scan(&source); err != nil || source != "syb" {
|
||||||
|
t.Fatalf("骨架来源=%q err=%v", source, err)
|
||||||
|
}
|
||||||
|
var mappingCount int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM spec_mappings WHERE shopee_goods_id='REPORT-1' AND spec_key='黑色, M' AND pdd_goods_id='PDD-1'`).Scan(&mappingCount); err != nil || mappingCount != 1 {
|
||||||
|
t.Fatalf("旧映射转换数量=%d err=%v", mappingCount, err)
|
||||||
|
}
|
||||||
|
if exists, _ := mysqlTableExists(db, "sku_mappings"); exists {
|
||||||
|
t.Fatal("旧 sku_mappings 应已删除")
|
||||||
|
}
|
||||||
|
if exists, _ := mysqlTableExists(db, "sku_mappings_v3_backup"); !exists {
|
||||||
|
t.Fatal("删除旧表前必须保留受限备份表")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMySQLMigrate_V3三个中断点重跑收敛(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
prepare func(*testing.T, *sql.DB)
|
||||||
|
}{
|
||||||
|
{"DDL完成回填未开始", func(t *testing.T, db *sql.DB) {
|
||||||
|
if err := ensureMySQLV3Columns(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustExec(t, db, mysqlSchemaV3SpecMappings)
|
||||||
|
}},
|
||||||
|
{"回填到一半", func(t *testing.T, db *sql.DB) {
|
||||||
|
if err := ensureMySQLV3Columns(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustExec(t, db, mysqlSchemaV3SpecMappings)
|
||||||
|
key, _ := spec.SpecKey("黑色,M")
|
||||||
|
mustExec(t, db, `UPDATE syb_orders SET spec_key=? WHERE syb_id='SYB-A'`, key)
|
||||||
|
}},
|
||||||
|
{"全部完成版本未记录", func(t *testing.T, db *sql.DB) {
|
||||||
|
if err := migrateMySQLV3(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
db := openMySQLMigrationTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
cleanMySQLTestSchema(t, db)
|
||||||
|
defer cleanMySQLTestSchema(t, db)
|
||||||
|
prepareMySQLV2(t, db)
|
||||||
|
now := "2026-08-10T03:00:00Z"
|
||||||
|
mustExec(t, db, `INSERT INTO syb_orders (syb_id,order_no,title,shopee_goods_id,product_spec,quantity,syb_data,created_at,updated_at) VALUES ('SYB-A','A','A','G-A','黑色,M',1,'{}',?,?),('SYB-B','B','B','G-B','白色,L',1,'{}',?,?)`, now, now, now, now)
|
||||||
|
tc.prepare(t, db)
|
||||||
|
if err := MigrateMySQL(db); err != nil {
|
||||||
|
t.Fatalf("重跑失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := MigrateMySQL(db); err != nil {
|
||||||
|
t.Fatalf("再次重跑失败: %v", err)
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM syb_orders WHERE spec_key IS NOT NULL`).Scan(&count); err != nil || count != 2 {
|
||||||
|
t.Fatalf("回填未收敛 count=%d err=%v", count, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMySQLMigrate_V3字段存在但约束缺失可收敛(t *testing.T) {
|
||||||
|
db := openMySQLMigrationTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
cleanMySQLTestSchema(t, db)
|
||||||
|
defer cleanMySQLTestSchema(t, db)
|
||||||
|
prepareMySQLV2(t, db)
|
||||||
|
mustExec(t, db, `INSERT INTO shopee_products(goods_id,title,created_at,updated_at) VALUES ('EXISTING-1','存量商品','2026-08-10T00:00:00Z','2026-08-10T00:00:00Z')`)
|
||||||
|
mustExec(t, db, `ALTER TABLE shopee_products ADD COLUMN source VARCHAR(16) NULL`)
|
||||||
|
if err := MigrateMySQL(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var existingSource string
|
||||||
|
if err := db.QueryRow(`SELECT source FROM shopee_products WHERE goods_id='EXISTING-1'`).Scan(&existingSource); err != nil || existingSource != "report" {
|
||||||
|
t.Fatalf("可空 source 中断点的存量行未收敛: source=%q err=%v", existingSource, err)
|
||||||
|
}
|
||||||
|
mustExec(t, db, `INSERT INTO shopee_products(goods_id,title,source,created_at,updated_at) VALUES ('CHECK-1','检查','report','2026-08-10T00:00:00Z','2026-08-10T00:00:00Z')`)
|
||||||
|
if _, err := db.Exec(`UPDATE shopee_products SET source='typo'`); err == nil {
|
||||||
|
t.Fatal("source CHECK 必须拒绝非法值")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMySQLMigrate_V3形状自检失败不记版本(t *testing.T) {
|
||||||
|
db := openMySQLMigrationTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
cleanMySQLTestSchema(t, db)
|
||||||
|
defer cleanMySQLTestSchema(t, db)
|
||||||
|
prepareMySQLV2(t, db)
|
||||||
|
mustExec(t, db, `CREATE TABLE spec_mappings (
|
||||||
|
shopee_goods_id VARCHAR(191) NOT NULL,
|
||||||
|
spec_key VARCHAR(191) NOT NULL,
|
||||||
|
pdd_goods_id VARCHAR(191) NOT NULL,
|
||||||
|
pdd_option_key VARCHAR(191) NOT NULL,
|
||||||
|
pdd_options LONGTEXT NOT NULL,
|
||||||
|
spec_raw TEXT NOT NULL,
|
||||||
|
mapped_at VARCHAR(35) NOT NULL,
|
||||||
|
PRIMARY KEY (shopee_goods_id, pdd_goods_id, spec_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`)
|
||||||
|
if err := MigrateMySQL(db); err == nil {
|
||||||
|
t.Fatal("错误主键顺序必须让 v3 形状自检失败")
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=3`).Scan(&count); err != nil || count != 0 {
|
||||||
|
t.Fatalf("自检失败时不得记录 v3: count=%d err=%v", count, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openMySQLMigrationTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" {
|
||||||
|
t.Skip("未启用真实 MySQL 8 集成测试")
|
||||||
|
}
|
||||||
|
cfg, err := config.LoadDatabaseFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(cfg.Name, "_test") {
|
||||||
|
t.Fatalf("拒绝使用非 _test 数据库 %q", cfg.Name)
|
||||||
|
}
|
||||||
|
db, err := OpenMySQL(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareMySQLV2(t *testing.T, db *sql.DB) {
|
||||||
|
t.Helper()
|
||||||
|
mustExec(t, db, `CREATE TABLE schema_migrations (version INT PRIMARY KEY, applied_at VARCHAR(35) NOT NULL) ENGINE=InnoDB`)
|
||||||
|
for _, statement := range mysqlSchemaV1 {
|
||||||
|
mustExec(t, db, statement)
|
||||||
|
}
|
||||||
|
for _, statement := range mysqlSchemaV2 {
|
||||||
|
mustExec(t, db, statement)
|
||||||
|
}
|
||||||
|
mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES (1,'2026-08-10T00:00:00Z'),(2,'2026-08-10T00:00:00Z')`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := db.Exec(query, args...); err != nil {
|
||||||
|
t.Fatalf("执行 SQL 失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func cleanMySQLTestSchema(t *testing.T, db *sql.DB) {
|
func cleanMySQLTestSchema(t *testing.T, db *sql.DB) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
rows, err := db.Query(`SELECT table_name FROM information_schema.tables
|
rows, err := db.Query(`SELECT table_name FROM information_schema.tables
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ func pddListFilter(keyword string, status model.CollectStatus) (string, []any) {
|
|||||||
// ListPddProducts 查一页列表和当前筛选下的总数。
|
// ListPddProducts 查一页列表和当前筛选下的总数。
|
||||||
// keyword 匹配 goods_id 或链接,status 为空表示不筛选。
|
// keyword 匹配 goods_id 或链接,status 为空表示不筛选。
|
||||||
//
|
//
|
||||||
// 只返回未删除的。软删除的记录还在库里(sku_mappings 指着它),
|
// 只返回未删除的。软删除的记录还在库里(spec_mappings 仍会引用它),
|
||||||
// 但界面上不该看见。
|
// 但界面上不该看见。
|
||||||
func ListPddProducts(q Execer, keyword string, status model.CollectStatus, limit, offset int) ([]PddProductRow, int, error) {
|
func ListPddProducts(q Execer, keyword string, status model.CollectStatus, limit, offset int) ([]PddProductRow, int, error) {
|
||||||
where, args := pddListFilter(keyword, status)
|
where, args := pddListFilter(keyword, status)
|
||||||
@@ -414,7 +414,7 @@ func MarkCollecting(q Execer, pddGoodsID string) (bool, error) {
|
|||||||
|
|
||||||
// SoftDeletePddProduct 软删除。
|
// SoftDeletePddProduct 软删除。
|
||||||
//
|
//
|
||||||
// 不硬删是因为 sku_mappings 指向它,硬删会把人工攒了很久的匹配成果一起带走。
|
// 不硬删是因为 spec_mappings 指向它,硬删会把人工攒了很久的匹配成果一起带走。
|
||||||
// 删除后界面上不再显示,但记录和映射都还在;
|
// 删除后界面上不再显示,但记录和映射都还在;
|
||||||
// 操作员重新填同一个链接时会被 EnsurePddProduct 复活。
|
// 操作员重新填同一个链接时会被 EnsurePddProduct 复活。
|
||||||
func SoftDeletePddProduct(q Execer, pddGoodsID string) error {
|
func SoftDeletePddProduct(q Execer, pddGoodsID string) error {
|
||||||
|
|||||||
@@ -22,12 +22,13 @@ func UpsertShopeeProduct(q Execer, goodsID, title, shopeeStatus, mainSKUCode str
|
|||||||
}
|
}
|
||||||
now := model.NowISO()
|
now := model.NowISO()
|
||||||
_, err := q.Exec(`
|
_, err := q.Exec(`
|
||||||
INSERT INTO shopee_products (goods_id, title, shopee_status, main_sku_code, created_at, updated_at)
|
INSERT INTO shopee_products (goods_id, title, shopee_status, main_sku_code, source, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, 'report', ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
title = VALUES(title),
|
title = VALUES(title),
|
||||||
shopee_status = VALUES(shopee_status),
|
shopee_status = VALUES(shopee_status),
|
||||||
main_sku_code = VALUES(main_sku_code),
|
main_sku_code = VALUES(main_sku_code),
|
||||||
|
source = 'report',
|
||||||
updated_at = VALUES(updated_at)`,
|
updated_at = VALUES(updated_at)`,
|
||||||
// 注意:pdd_goods_url / pdd_goods_id 两列不在 INSERT 的列清单里,
|
// 注意:pdd_goods_url / pdd_goods_id 两列不在 INSERT 的列清单里,
|
||||||
// 也不在 DO UPDATE SET 里——新建时它们是 NULL(未填链接),
|
// 也不在 DO UPDATE SET 里——新建时它们是 NULL(未填链接),
|
||||||
@@ -162,7 +163,7 @@ func shopeeFilterClause(filter ShopeeFilter) (string, []any) {
|
|||||||
func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]ShopeeProductRow, error) {
|
func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]ShopeeProductRow, error) {
|
||||||
where, args := shopeeFilterClause(filter)
|
where, args := shopeeFilterClause(filter)
|
||||||
sqlText := `
|
sqlText := `
|
||||||
SELECT sp.goods_id, sp.title, sp.shopee_status, sp.main_sku_code,
|
SELECT sp.goods_id, sp.title, sp.shopee_status, sp.main_sku_code, sp.source,
|
||||||
sp.pdd_goods_url, sp.pdd_goods_id, sp.created_at, sp.updated_at,
|
sp.pdd_goods_url, sp.pdd_goods_id, sp.created_at, sp.updated_at,
|
||||||
COUNT(DISTINCT CASE WHEN sk.parse_ok = 1 THEN sk.color END) AS color_count,
|
COUNT(DISTINCT CASE WHEN sk.parse_ok = 1 THEN sk.color END) AS color_count,
|
||||||
COUNT(DISTINCT CASE WHEN sk.parse_ok = 1 THEN sk.size END) AS size_count,
|
COUNT(DISTINCT CASE WHEN sk.parse_ok = 1 THEN sk.size END) AS size_count,
|
||||||
@@ -185,9 +186,9 @@ func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]Sho
|
|||||||
var list []ShopeeProductRow
|
var list []ShopeeProductRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r ShopeeProductRow
|
var r ShopeeProductRow
|
||||||
var title, shopeeStatus, mainSKUCode, pddGoodsURL, pddGoodsID sql.NullString
|
var title, shopeeStatus, mainSKUCode, source, pddGoodsURL, pddGoodsID sql.NullString
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&r.GoodsID, &title, &shopeeStatus, &mainSKUCode,
|
&r.GoodsID, &title, &shopeeStatus, &mainSKUCode, &source,
|
||||||
&pddGoodsURL, &pddGoodsID, &r.CreatedAt, &r.UpdatedAt,
|
&pddGoodsURL, &pddGoodsID, &r.CreatedAt, &r.UpdatedAt,
|
||||||
&r.ColorCount, &r.SizeCount, &r.SKUCount, &r.PendingCount,
|
&r.ColorCount, &r.SizeCount, &r.SKUCount, &r.PendingCount,
|
||||||
&r.CollectStatus, &r.CollectMsg,
|
&r.CollectStatus, &r.CollectMsg,
|
||||||
@@ -197,6 +198,7 @@ func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]Sho
|
|||||||
r.Title = title.String
|
r.Title = title.String
|
||||||
r.ShopeeStatus = shopeeStatus.String
|
r.ShopeeStatus = shopeeStatus.String
|
||||||
r.MainSKUCode = mainSKUCode.String
|
r.MainSKUCode = mainSKUCode.String
|
||||||
|
r.Source = source.String
|
||||||
r.PddGoodsURL = pddGoodsURL.String
|
r.PddGoodsURL = pddGoodsURL.String
|
||||||
r.PddGoodsID = pddGoodsID.String
|
r.PddGoodsID = pddGoodsID.String
|
||||||
list = append(list, r)
|
list = append(list, r)
|
||||||
@@ -223,12 +225,12 @@ func CountShopeeProductsFiltered(q Execer, filter ShopeeFilter) (int, error) {
|
|||||||
// 弹窗组装商品信息时用。查不到返回 (nil, nil)。
|
// 弹窗组装商品信息时用。查不到返回 (nil, nil)。
|
||||||
func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct, error) {
|
func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct, error) {
|
||||||
var p model.ShopeeProduct
|
var p model.ShopeeProduct
|
||||||
var title, shopeeStatus, mainSKUCode, pddGoodsURL, pddGoodsID sql.NullString
|
var title, shopeeStatus, mainSKUCode, source, pddGoodsURL, pddGoodsID sql.NullString
|
||||||
err := q.QueryRow(`
|
err := q.QueryRow(`
|
||||||
SELECT goods_id, title, shopee_status, main_sku_code,
|
SELECT goods_id, title, shopee_status, main_sku_code, source,
|
||||||
pdd_goods_url, pdd_goods_id, created_at, updated_at
|
pdd_goods_url, pdd_goods_id, created_at, updated_at
|
||||||
FROM shopee_products WHERE goods_id = ?`, goodsID).Scan(
|
FROM shopee_products WHERE goods_id = ?`, goodsID).Scan(
|
||||||
&p.GoodsID, &title, &shopeeStatus, &mainSKUCode,
|
&p.GoodsID, &title, &shopeeStatus, &mainSKUCode, &source,
|
||||||
&pddGoodsURL, &pddGoodsID, &p.CreatedAt, &p.UpdatedAt)
|
&pddGoodsURL, &pddGoodsID, &p.CreatedAt, &p.UpdatedAt)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -239,6 +241,7 @@ func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct,
|
|||||||
p.Title = title.String
|
p.Title = title.String
|
||||||
p.ShopeeStatus = shopeeStatus.String
|
p.ShopeeStatus = shopeeStatus.String
|
||||||
p.MainSKUCode = mainSKUCode.String
|
p.MainSKUCode = mainSKUCode.String
|
||||||
|
p.Source = source.String
|
||||||
p.PddGoodsURL = pddGoodsURL.String
|
p.PddGoodsURL = pddGoodsURL.String
|
||||||
p.PddGoodsID = pddGoodsID.String
|
p.PddGoodsID = pddGoodsID.String
|
||||||
return &p, nil
|
return &p, nil
|
||||||
|
|||||||
+42
-64
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"cmautobuy/admin/model"
|
"cmautobuy/admin/model"
|
||||||
|
"cmautobuy/admin/spec"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------- 会话缓存 ----------
|
// ---------- 会话缓存 ----------
|
||||||
@@ -224,11 +225,8 @@ func CountSybSyncRuns(q Execer) (int, error) {
|
|||||||
|
|
||||||
// UpsertSybOrder 写入或更新一条顺运宝货运单明细行。
|
// UpsertSybOrder 写入或更新一条顺运宝货运单明细行。
|
||||||
//
|
//
|
||||||
// `[必须]` DO UPDATE SET 里绝不允许出现 shopee_sku_id。它是规格匹配的
|
// `[必须]` shopee_sku_id 已退出采购主链路,INSERT 和 UPDATE 都不再写它。
|
||||||
// 结果(人工确认或自动匹配产生),顺运宝那边根本没有这个值——写进去
|
// 旧列只为只追加迁移兼容保留。
|
||||||
// 就是写 NULL,把人工攒的匹配成果洗掉,而且不报错。这和 #38 里
|
|
||||||
// pdd_goods_url 不能被 Excel 导入覆盖是同一类问题,见工单 #46、
|
|
||||||
// docs/admin/08-顺运宝接口.md §6.2。
|
|
||||||
//
|
//
|
||||||
// `[必须]` shopee_goods_id **可以**被覆盖——它就是顺运宝
|
// `[必须]` shopee_goods_id **可以**被覆盖——它就是顺运宝
|
||||||
// detail.productId,来自顺运宝,不是人工填的。
|
// detail.productId,来自顺运宝,不是人工填的。
|
||||||
@@ -238,6 +236,14 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
|
|||||||
if o.SybID == "" {
|
if o.SybID == "" {
|
||||||
return false, fmt.Errorf("syb_id 不能为空")
|
return false, fmt.Errorf("syb_id 不能为空")
|
||||||
}
|
}
|
||||||
|
var specKey any
|
||||||
|
if strings.TrimSpace(o.ProductSpec) != "" {
|
||||||
|
key, keyErr := spec.SpecKey(o.ProductSpec)
|
||||||
|
if keyErr != nil {
|
||||||
|
return false, fmt.Errorf("顺运宝明细 %s 的规格不能生成身份键: %w", o.SybID, keyErr)
|
||||||
|
}
|
||||||
|
specKey = key
|
||||||
|
}
|
||||||
|
|
||||||
var exists int
|
var exists int
|
||||||
err = q.QueryRow(`SELECT 1 FROM syb_orders WHERE syb_id = ?`, o.SybID).Scan(&exists)
|
err = q.QueryRow(`SELECT 1 FROM syb_orders WHERE syb_id = ?`, o.SybID).Scan(&exists)
|
||||||
@@ -249,26 +255,33 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := model.NowISO()
|
now := model.NowISO()
|
||||||
|
if strings.TrimSpace(o.ShopeeGoodsID) != "" {
|
||||||
|
if _, err := q.Exec(`
|
||||||
|
INSERT INTO shopee_products (goods_id, title, source, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 'syb', ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE goods_id=VALUES(goods_id)`,
|
||||||
|
o.ShopeeGoodsID, o.Title, now, now); err != nil {
|
||||||
|
return false, fmt.Errorf("为顺运宝明细 %s 补建蝦皮商品骨架失败: %w", o.SybID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
_, err = q.Exec(`
|
_, err = q.Exec(`
|
||||||
INSERT INTO syb_orders
|
INSERT INTO syb_orders
|
||||||
(syb_id, order_no, title, product_spec, shopee_goods_id, shopee_sku_id,
|
(syb_id, order_no, title, product_spec, spec_key, shopee_goods_id,
|
||||||
quantity, price_twd_cent, image_url, syb_data, created_at, updated_at)
|
quantity, price_twd_cent, image_url, syb_data, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
order_no = VALUES(order_no),
|
order_no = VALUES(order_no),
|
||||||
title = VALUES(title),
|
title = VALUES(title),
|
||||||
product_spec = VALUES(product_spec),
|
product_spec = VALUES(product_spec),
|
||||||
|
spec_key = VALUES(spec_key),
|
||||||
shopee_goods_id = VALUES(shopee_goods_id),
|
shopee_goods_id = VALUES(shopee_goods_id),
|
||||||
quantity = VALUES(quantity),
|
quantity = VALUES(quantity),
|
||||||
price_twd_cent = VALUES(price_twd_cent),
|
price_twd_cent = VALUES(price_twd_cent),
|
||||||
image_url = VALUES(image_url),
|
image_url = VALUES(image_url),
|
||||||
syb_data = VALUES(syb_data),
|
syb_data = VALUES(syb_data),
|
||||||
updated_at = VALUES(updated_at)`,
|
updated_at = VALUES(updated_at)`,
|
||||||
// 注意:shopee_sku_id 只出现在 INSERT 的列清单里(新建行时写 o.ShopeeSKUID,
|
o.SybID, o.OrderNo, o.Title, nullableText(o.ProductSpec), specKey, nullableText(o.ShopeeGoodsID),
|
||||||
// 同步永远传空字符串),完全不出现在 DO UPDATE SET 里——已存在的行
|
o.Quantity, o.PriceTwdCent, nullableText(o.ImageURL),
|
||||||
// 这一列不受本语句影响,见上面的函数注释。
|
|
||||||
o.SybID, o.OrderNo, o.Title, nullableText(o.ProductSpec), nullableText(o.ShopeeGoodsID),
|
|
||||||
nullableText(o.ShopeeSKUID), o.Quantity, o.PriceTwdCent, nullableText(o.ImageURL),
|
|
||||||
o.SybData, now, now)
|
o.SybData, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("写入顺运宝货运单明细 %s 失败: %w", o.SybID, err)
|
return false, fmt.Errorf("写入顺运宝货运单明细 %s 失败: %w", o.SybID, err)
|
||||||
@@ -301,14 +314,12 @@ func sybOrderFilterClause(filter SybOrderFilter) (string, []any) {
|
|||||||
args = append(args, like, like)
|
args = append(args, like, like)
|
||||||
}
|
}
|
||||||
switch filter.Stage {
|
switch filter.Stage {
|
||||||
case "missing_shopee":
|
case "spec_missing":
|
||||||
clauses = append(clauses, `sp.goods_id IS NULL`)
|
clauses = append(clauses, `so.spec_key IS NULL`)
|
||||||
case "sku_pending":
|
|
||||||
clauses = append(clauses, `sp.goods_id IS NOT NULL AND so.shopee_sku_id IS NULL`)
|
|
||||||
case "pdd_missing":
|
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)`)
|
clauses = append(clauses, `so.spec_key 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":
|
case "pdd_pending", "pdd_collecting", "pdd_failed", "pdd_collected":
|
||||||
clauses = append(clauses, `so.shopee_sku_id IS NOT NULL AND pp.collect_status = ?`)
|
clauses = append(clauses, `so.spec_key IS NOT NULL AND pp.collect_status = ?`)
|
||||||
args = append(args, strings.TrimPrefix(filter.Stage, "pdd_"))
|
args = append(args, strings.TrimPrefix(filter.Stage, "pdd_"))
|
||||||
}
|
}
|
||||||
if len(clauses) == 0 {
|
if len(clauses) == 0 {
|
||||||
@@ -321,8 +332,9 @@ const sybOrderContextFrom = `
|
|||||||
FROM syb_orders so
|
FROM syb_orders so
|
||||||
LEFT JOIN shopee_products sp ON sp.goods_id = so.shopee_goods_id
|
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
|
LEFT JOIN pdd_products pp ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL
|
||||||
LEFT JOIN sku_mappings sm
|
LEFT JOIN spec_mappings sm
|
||||||
ON sm.shopee_sku_id = so.shopee_sku_id AND sm.pdd_goods_id = sp.pdd_goods_id`
|
ON sm.shopee_goods_id = so.shopee_goods_id
|
||||||
|
AND sm.spec_key = so.spec_key AND sm.pdd_goods_id = sp.pdd_goods_id`
|
||||||
|
|
||||||
// SybOrderContext 是顺运宝明细及其当前蝦皮/PDD 处理上下文。
|
// SybOrderContext 是顺运宝明细及其当前蝦皮/PDD 处理上下文。
|
||||||
// 处理阶段由 service 计算,Repository 只提供数据库事实。
|
// 处理阶段由 service 计算,Repository 只提供数据库事实。
|
||||||
@@ -341,14 +353,14 @@ type SybOrderContext struct {
|
|||||||
|
|
||||||
func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
|
func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
|
||||||
var c SybOrderContext
|
var c SybOrderContext
|
||||||
var title, productSpec, shopeeGoodsID, shopeeSKUID, imageURL sql.NullString
|
var title, productSpec, specKey, shopeeGoodsID, imageURL sql.NullString
|
||||||
var priceCent sql.NullInt64
|
var priceCent sql.NullInt64
|
||||||
var shopeeExists int
|
var shopeeExists int
|
||||||
var pddGoodsID, pddGoodsURL, collectStatus, collectMsg, skusJSON sql.NullString
|
var pddGoodsID, pddGoodsURL, collectStatus, collectMsg, skusJSON sql.NullString
|
||||||
var mappingKey, mappingOptions sql.NullString
|
var mappingKey, mappingOptions sql.NullString
|
||||||
var hasActiveTask int
|
var hasActiveTask int
|
||||||
err := s.Scan(
|
err := s.Scan(
|
||||||
&c.Order.SybID, &c.Order.OrderNo, &title, &productSpec, &shopeeGoodsID, &shopeeSKUID,
|
&c.Order.SybID, &c.Order.OrderNo, &title, &productSpec, &specKey, &shopeeGoodsID,
|
||||||
&c.Order.Quantity, &priceCent, &imageURL, &c.Order.SybData,
|
&c.Order.Quantity, &priceCent, &imageURL, &c.Order.SybData,
|
||||||
&c.Order.CreatedAt, &c.Order.UpdatedAt, &shopeeExists,
|
&c.Order.CreatedAt, &c.Order.UpdatedAt, &shopeeExists,
|
||||||
&pddGoodsID, &pddGoodsURL, &collectStatus, &collectMsg, &skusJSON,
|
&pddGoodsID, &pddGoodsURL, &collectStatus, &collectMsg, &skusJSON,
|
||||||
@@ -356,8 +368,8 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
|
|||||||
)
|
)
|
||||||
c.Order.Title = title.String
|
c.Order.Title = title.String
|
||||||
c.Order.ProductSpec = productSpec.String
|
c.Order.ProductSpec = productSpec.String
|
||||||
|
c.Order.SpecKey = specKey.String
|
||||||
c.Order.ShopeeGoodsID = shopeeGoodsID.String
|
c.Order.ShopeeGoodsID = shopeeGoodsID.String
|
||||||
c.Order.ShopeeSKUID = shopeeSKUID.String
|
|
||||||
c.Order.PriceTwdCent = priceCent.Int64
|
c.Order.PriceTwdCent = priceCent.Int64
|
||||||
c.Order.ImageURL = imageURL.String
|
c.Order.ImageURL = imageURL.String
|
||||||
c.ShopeeExists = shopeeExists != 0
|
c.ShopeeExists = shopeeExists != 0
|
||||||
@@ -376,8 +388,8 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
|
|||||||
func ListSybOrderContexts(q Execer, filter SybOrderFilter, limit, offset int) ([]SybOrderContext, error) {
|
func ListSybOrderContexts(q Execer, filter SybOrderFilter, limit, offset int) ([]SybOrderContext, error) {
|
||||||
where, args := sybOrderFilterClause(filter)
|
where, args := sybOrderFilterClause(filter)
|
||||||
query := `
|
query := `
|
||||||
SELECT so.syb_id, so.order_no, so.title, so.product_spec,
|
SELECT so.syb_id, so.order_no, so.title, so.product_spec, so.spec_key,
|
||||||
so.shopee_goods_id, so.shopee_sku_id, so.quantity,
|
so.shopee_goods_id, so.quantity,
|
||||||
so.price_twd_cent, so.image_url, so.syb_data, so.created_at, so.updated_at,
|
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,
|
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,
|
sp.pdd_goods_id, sp.pdd_goods_url, pp.collect_status, pp.collect_msg,
|
||||||
@@ -410,8 +422,8 @@ func ListSybOrderContexts(q Execer, filter SybOrderFilter, limit, offset int) ([
|
|||||||
// GetSybOrderContext 按明细 ID 读取一条处理上下文。
|
// GetSybOrderContext 按明细 ID 读取一条处理上下文。
|
||||||
func GetSybOrderContext(q Execer, sybID string) (*SybOrderContext, error) {
|
func GetSybOrderContext(q Execer, sybID string) (*SybOrderContext, error) {
|
||||||
row, err := scanSybOrderContext(q.QueryRow(`
|
row, err := scanSybOrderContext(q.QueryRow(`
|
||||||
SELECT so.syb_id, so.order_no, so.title, so.product_spec,
|
SELECT so.syb_id, so.order_no, so.title, so.product_spec, so.spec_key,
|
||||||
so.shopee_goods_id, so.shopee_sku_id, so.quantity,
|
so.shopee_goods_id, so.quantity,
|
||||||
so.price_twd_cent, so.image_url, so.syb_data, so.created_at, so.updated_at,
|
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,
|
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,
|
sp.pdd_goods_id, sp.pdd_goods_url, pp.collect_status, pp.collect_msg,
|
||||||
@@ -429,45 +441,11 @@ func GetSybOrderContext(q Execer, sybID string) (*SybOrderContext, error) {
|
|||||||
return &row, nil
|
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 按筛选条件分页查货运单明细列表,按更新时间倒序。
|
// ListSybOrders 按筛选条件分页查货运单明细列表,按更新时间倒序。
|
||||||
func ListSybOrders(q Execer, filter SybOrderFilter, limit, offset int) ([]model.SybOrder, error) {
|
func ListSybOrders(q Execer, filter SybOrderFilter, limit, offset int) ([]model.SybOrder, error) {
|
||||||
where, args := sybOrderFilterClause(filter)
|
where, args := sybOrderFilterClause(filter)
|
||||||
sqlText := `
|
sqlText := `
|
||||||
SELECT so.syb_id, so.order_no, so.title, so.product_spec, so.shopee_goods_id, so.shopee_sku_id,
|
SELECT so.syb_id, so.order_no, so.title, so.product_spec, so.spec_key, so.shopee_goods_id,
|
||||||
so.quantity, so.price_twd_cent, so.image_url, so.syb_data, so.created_at, so.updated_at` +
|
so.quantity, so.price_twd_cent, so.image_url, so.syb_data, so.created_at, so.updated_at` +
|
||||||
sybOrderContextFrom + where + `
|
sybOrderContextFrom + where + `
|
||||||
ORDER BY so.updated_at DESC, so.syb_id DESC LIMIT ? OFFSET ?`
|
ORDER BY so.updated_at DESC, so.syb_id DESC LIMIT ? OFFSET ?`
|
||||||
@@ -482,18 +460,18 @@ func ListSybOrders(q Execer, filter SybOrderFilter, limit, offset int) ([]model.
|
|||||||
var list []model.SybOrder
|
var list []model.SybOrder
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var o model.SybOrder
|
var o model.SybOrder
|
||||||
var title, productSpec, shopeeGoodsID, shopeeSKUID, imageURL sql.NullString
|
var title, productSpec, specKey, shopeeGoodsID, imageURL sql.NullString
|
||||||
var priceCent sql.NullInt64
|
var priceCent sql.NullInt64
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&o.SybID, &o.OrderNo, &title, &productSpec, &shopeeGoodsID, &shopeeSKUID,
|
&o.SybID, &o.OrderNo, &title, &productSpec, &specKey, &shopeeGoodsID,
|
||||||
&o.Quantity, &priceCent, &imageURL, &o.SybData, &o.CreatedAt, &o.UpdatedAt,
|
&o.Quantity, &priceCent, &imageURL, &o.SybData, &o.CreatedAt, &o.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("读取顺运宝货运单列表失败: %w", err)
|
return nil, fmt.Errorf("读取顺运宝货运单列表失败: %w", err)
|
||||||
}
|
}
|
||||||
o.Title = title.String
|
o.Title = title.String
|
||||||
o.ProductSpec = productSpec.String
|
o.ProductSpec = productSpec.String
|
||||||
|
o.SpecKey = specKey.String
|
||||||
o.ShopeeGoodsID = shopeeGoodsID.String
|
o.ShopeeGoodsID = shopeeGoodsID.String
|
||||||
o.ShopeeSKUID = shopeeSKUID.String
|
|
||||||
o.PriceTwdCent = priceCent.Int64
|
o.PriceTwdCent = priceCent.Int64
|
||||||
o.ImageURL = imageURL.String
|
o.ImageURL = imageURL.String
|
||||||
list = append(list, o)
|
list = append(list, o)
|
||||||
|
|||||||
@@ -167,6 +167,25 @@ func TestUpsertSybOrder_重复同步同一订单行数不翻倍(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpsertSybOrder_骨架失败时同事务不写订单(t *testing.T) {
|
||||||
|
db := newSybTestDB(t)
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := UpsertSybOrder(tx, model.SybOrder{
|
||||||
|
SybID: "SYB-REJECT", OrderNo: "ORDER-REJECT", Title: "测试",
|
||||||
|
ShopeeGoodsID: strings.Repeat("G", 192), ProductSpec: "黑色,M", Quantity: 1, SybData: "{}",
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("骨架失败必须让订单写入失败")
|
||||||
|
}
|
||||||
|
tx.Rollback()
|
||||||
|
var count int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM syb_orders WHERE syb_id='SYB-REJECT'`).Scan(&count); err != nil || count != 0 {
|
||||||
|
t.Fatalf("失败后不应留下订单 count=%d err=%v", count, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSybSession_保存读取和清除(t *testing.T) {
|
func TestSybSession_保存读取和清除(t *testing.T) {
|
||||||
db := newSybTestDB(t)
|
db := newSybTestDB(t)
|
||||||
|
|
||||||
|
|||||||
@@ -506,7 +506,7 @@ func CreatePddProduct(db *sql.DB, rawURL string) (string, error) {
|
|||||||
// UpdatePddProductURL 保存弹窗里改过的链接。
|
// UpdatePddProductURL 保存弹窗里改过的链接。
|
||||||
//
|
//
|
||||||
// `[必须]` 新链接必须还是同一个商品。换成别的商品要新建一条,不能就地改:
|
// `[必须]` 新链接必须还是同一个商品。换成别的商品要新建一条,不能就地改:
|
||||||
// 这一行上挂着采集结果和 sku_mappings,goods_id 一换,
|
// 这一行上挂着采集结果和 spec_mappings,goods_id 一换,
|
||||||
// 那些数据就全指到错的商品上了,之后按它下单就是买错东西。
|
// 那些数据就全指到错的商品上了,之后按它下单就是买错东西。
|
||||||
func UpdatePddProductURL(db *sql.DB, id int64, rawURL string) error {
|
func UpdatePddProductURL(db *sql.DB, id int64, rawURL string) error {
|
||||||
goodsID, err := ParsePddGoodsID(rawURL)
|
goodsID, err := ParsePddGoodsID(rawURL)
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ func TestUpdatePddProductURL_同一个商品可以改写法(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 换成另一个商品必须拒绝:这一行上挂着采集结果和 sku_mappings,
|
// 换成另一个商品必须拒绝:这一行上挂着采集结果和 spec_mappings,
|
||||||
// goods_id 一换那些数据就全指到错的商品上,之后按它下单就是买错东西。
|
// goods_id 一换那些数据就全指到错的商品上,之后按它下单就是买错东西。
|
||||||
func TestUpdatePddProductURL_换成别的商品要拒绝(t *testing.T) {
|
func TestUpdatePddProductURL_换成别的商品要拒绝(t *testing.T) {
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
|
|||||||
+25
-47
@@ -198,49 +198,30 @@ func TestMarkCollecting_失败后可重新采集(t *testing.T) {
|
|||||||
|
|
||||||
// ── 映射隔离:本工单的安全核心 ─────────────────────────
|
// ── 映射隔离:本工单的安全核心 ─────────────────────────
|
||||||
|
|
||||||
// insertShopeeSKU 造蝦皮商品和它的一个规格。
|
|
||||||
// sku_mappings 有外键指向 shopee_skus,映射相关的测试都要先造它。
|
|
||||||
func insertShopeeSKU(t *testing.T, db *sql.DB, goodsID, skuID string) {
|
|
||||||
t.Helper()
|
|
||||||
now := model.NowISO()
|
|
||||||
if _, err := db.Exec(`
|
|
||||||
INSERT INTO shopee_products (goods_id, title, created_at, updated_at)
|
|
||||||
VALUES (?, '测试商品', ?, ?)
|
|
||||||
ON DUPLICATE KEY UPDATE goods_id = VALUES(goods_id)`, goodsID, now, now); err != nil {
|
|
||||||
t.Fatalf("插入蝦皮商品失败: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := db.Exec(`
|
|
||||||
INSERT INTO shopee_skus (sku_id, goods_id, spec_raw, created_at, updated_at)
|
|
||||||
VALUES (?, ?, '黑色,M', ?, ?)`, skuID, goodsID, now, now); err != nil {
|
|
||||||
t.Fatalf("插入蝦皮规格失败: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// insertMapping 造一条规格映射。
|
// insertMapping 造一条规格映射。
|
||||||
func insertMapping(t *testing.T, db *sql.DB, shopeeSKU, pddGoodsID string, options map[string]string) {
|
func insertMapping(t *testing.T, db *sql.DB, goodsID, specKey, pddGoodsID string, options map[string]string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
key, err := OptionKey(options)
|
key, err := OptionKey(options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := db.Exec(`
|
if err := repository.UpsertSpecMapping(db, model.SpecMapping{
|
||||||
INSERT INTO sku_mappings (shopee_sku_id, pdd_goods_id, pdd_option_key,
|
ShopeeGoodsID: goodsID, SpecKey: specKey, SpecRaw: specKey,
|
||||||
pdd_options, goods_id, mapped_at)
|
PddGoodsID: pddGoodsID, PddOptionKey: key, PddOptions: key, MappedAt: model.NowISO(),
|
||||||
VALUES (?, ?, ?, ?, 'SHOPEE-1', ?)`,
|
}); err != nil {
|
||||||
shopeeSKU, pddGoodsID, key, key, model.NowISO()); err != nil {
|
|
||||||
t.Fatalf("插入映射失败: %v", err)
|
t.Fatalf("插入映射失败: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// findMapping 模拟业务查询:**永远带上当前的 PDD 商品**。
|
// findMapping 模拟业务查询:**永远带上当前的 PDD 商品**。
|
||||||
// 这个条件就是隔离机制本身,少了它旧映射就会被误用。
|
// 这个条件就是隔离机制本身,少了它旧映射就会被误用。
|
||||||
func findMapping(t *testing.T, db *sql.DB, shopeeSKU, currentPddGoodsID string) (string, bool) {
|
func findMapping(t *testing.T, db *sql.DB, goodsID, specKey, currentPddGoodsID string) (string, bool) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var key string
|
var key string
|
||||||
err := db.QueryRow(`
|
err := db.QueryRow(`
|
||||||
SELECT pdd_option_key FROM sku_mappings
|
SELECT pdd_option_key FROM spec_mappings
|
||||||
WHERE shopee_sku_id = ? AND pdd_goods_id = ?`,
|
WHERE shopee_goods_id = ? AND spec_key = ? AND pdd_goods_id = ?`,
|
||||||
shopeeSKU, currentPddGoodsID).Scan(&key)
|
goodsID, specKey, currentPddGoodsID).Scan(&key)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -254,21 +235,20 @@ func findMapping(t *testing.T, db *sql.DB, shopeeSKU, currentPddGoodsID string)
|
|||||||
//
|
//
|
||||||
// 不隔离的话:B 恰好也有"黑色/M码"这个组合,但完全是另一件衣服,
|
// 不隔离的话:B 恰好也有"黑色/M码"这个组合,但完全是另一件衣服,
|
||||||
// 就会**静默买错东西**,而且事后查不出来。
|
// 就会**静默买错东西**,而且事后查不出来。
|
||||||
func TestSKUMapping_换PDD商品后查不到旧映射(t *testing.T) {
|
func TestSpecMapping_换PDD商品后查不到旧映射(t *testing.T) {
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
insertShopeeSKU(t, db, "SHOPEE-1", "SKU-1")
|
|
||||||
repository.EnsurePddProduct(db, "PDD-A", "https://x/a")
|
repository.EnsurePddProduct(db, "PDD-A", "https://x/a")
|
||||||
repository.EnsurePddProduct(db, "PDD-B", "https://x/b")
|
repository.EnsurePddProduct(db, "PDD-B", "https://x/b")
|
||||||
|
|
||||||
// 蝦皮 SKU 原来匹配到 PDD 商品 A 的"黑色/M码"
|
// 顺运宝规格原来匹配到 PDD 商品 A 的"黑色/M码"
|
||||||
insertMapping(t, db, "SKU-1", "PDD-A", map[string]string{"color": "黑色", "size": "M码"})
|
insertMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-A", map[string]string{"color": "黑色", "size": "M码"})
|
||||||
|
|
||||||
if _, ok := findMapping(t, db, "SKU-1", "PDD-A"); !ok {
|
if _, ok := findMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-A"); !ok {
|
||||||
t.Fatal("前置条件不对:A 的映射应该查得到")
|
t.Fatal("前置条件不对:A 的映射应该查得到")
|
||||||
}
|
}
|
||||||
|
|
||||||
// A 下架,换成 B。查 B 的映射应该查不到 —— 界面显示"待匹配"
|
// A 下架,换成 B。查 B 的映射应该查不到 —— 界面显示"待匹配"
|
||||||
if key, ok := findMapping(t, db, "SKU-1", "PDD-B"); ok {
|
if key, ok := findMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-B"); ok {
|
||||||
t.Errorf("换成 B 之后不该查到映射,却拿到了 %q —— "+
|
t.Errorf("换成 B 之后不该查到映射,却拿到了 %q —— "+
|
||||||
"这正是会导致买错商品的情况", key)
|
"这正是会导致买错商品的情况", key)
|
||||||
}
|
}
|
||||||
@@ -277,18 +257,17 @@ func TestSKUMapping_换PDD商品后查不到旧映射(t *testing.T) {
|
|||||||
// 换回原来的 PDD 商品,旧映射要还能用。
|
// 换回原来的 PDD 商品,旧映射要还能用。
|
||||||
// 这是把 pdd_goods_id 放进主键(而不是换商品时删掉旧映射)换来的好处:
|
// 这是把 pdd_goods_id 放进主键(而不是换商品时删掉旧映射)换来的好处:
|
||||||
// PDD 商品补货换回去时,之前的人工匹配成果直接复用。
|
// PDD 商品补货换回去时,之前的人工匹配成果直接复用。
|
||||||
func TestSKUMapping_换回原商品旧映射仍可用(t *testing.T) {
|
func TestSpecMapping_换回原商品旧映射仍可用(t *testing.T) {
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
insertShopeeSKU(t, db, "SHOPEE-1", "SKU-1")
|
|
||||||
repository.EnsurePddProduct(db, "PDD-A", "https://x/a")
|
repository.EnsurePddProduct(db, "PDD-A", "https://x/a")
|
||||||
repository.EnsurePddProduct(db, "PDD-B", "https://x/b")
|
repository.EnsurePddProduct(db, "PDD-B", "https://x/b")
|
||||||
|
|
||||||
insertMapping(t, db, "SKU-1", "PDD-A", map[string]string{"color": "黑色", "size": "M码"})
|
insertMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-A", map[string]string{"color": "黑色", "size": "M码"})
|
||||||
// 换到 B,也匹配一次
|
// 换到 B,也匹配一次
|
||||||
insertMapping(t, db, "SKU-1", "PDD-B", map[string]string{"color": "黑色", "size": "M"})
|
insertMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-B", map[string]string{"color": "黑色", "size": "M"})
|
||||||
|
|
||||||
// 换回 A
|
// 换回 A
|
||||||
key, ok := findMapping(t, db, "SKU-1", "PDD-A")
|
key, ok := findMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-A")
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("换回 A 之后,A 的旧映射应该还在")
|
t.Fatal("换回 A 之后,A 的旧映射应该还在")
|
||||||
}
|
}
|
||||||
@@ -298,24 +277,23 @@ func TestSKUMapping_换回原商品旧映射仍可用(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同一个蝦皮 SKU 可以同时有多个 PDD 商品的映射,互不干扰。
|
// 同一蝦皮商品规格可以同时有多个 PDD 商品的映射,互不干扰。
|
||||||
func TestSKUMapping_同一SKU可对多个PDD商品各有映射(t *testing.T) {
|
func TestSpecMapping_同一商品规格可对多个PDD商品各有映射(t *testing.T) {
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
insertShopeeSKU(t, db, "SHOPEE-1", "SKU-1")
|
|
||||||
repository.EnsurePddProduct(db, "PDD-A", "https://x/a")
|
repository.EnsurePddProduct(db, "PDD-A", "https://x/a")
|
||||||
repository.EnsurePddProduct(db, "PDD-B", "https://x/b")
|
repository.EnsurePddProduct(db, "PDD-B", "https://x/b")
|
||||||
|
|
||||||
insertMapping(t, db, "SKU-1", "PDD-A", map[string]string{"color": "黑色", "size": "M码"})
|
insertMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-A", map[string]string{"color": "黑色", "size": "M码"})
|
||||||
insertMapping(t, db, "SKU-1", "PDD-B", map[string]string{"color": "黑", "size": "均码"})
|
insertMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-B", map[string]string{"color": "黑", "size": "均码"})
|
||||||
|
|
||||||
var n int
|
var n int
|
||||||
db.QueryRow(`SELECT COUNT(*) FROM sku_mappings WHERE shopee_sku_id='SKU-1'`).Scan(&n)
|
db.QueryRow(`SELECT COUNT(*) FROM spec_mappings WHERE shopee_goods_id='SHOPEE-1' AND spec_key='黑色,M'`).Scan(&n)
|
||||||
if n != 2 {
|
if n != 2 {
|
||||||
t.Errorf("应能同时存在 2 条映射,实际 %d 条", n)
|
t.Errorf("应能同时存在 2 条映射,实际 %d 条", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
kA, _ := findMapping(t, db, "SKU-1", "PDD-A")
|
kA, _ := findMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-A")
|
||||||
kB, _ := findMapping(t, db, "SKU-1", "PDD-B")
|
kB, _ := findMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-B")
|
||||||
if kA == kB {
|
if kA == kB {
|
||||||
t.Error("两个 PDD 商品的映射不该相同")
|
t.Error("两个 PDD 商品的映射不该相同")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"cmautobuy/admin/model"
|
"cmautobuy/admin/model"
|
||||||
"cmautobuy/admin/repository"
|
"cmautobuy/admin/repository"
|
||||||
|
"cmautobuy/admin/spec"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PddOptionChoice 是采集结果中的一个可购买规格组合。
|
// PddOptionChoice 是采集结果中的一个可购买规格组合。
|
||||||
@@ -75,7 +76,7 @@ func findPddChoice(raw, key string) (*PddOptionChoice, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveSybMapping 保存顺运宝明细对应蝦皮 SKU 到当前 PDD 商品的可复用映射。
|
// SaveSybMapping 保存顺运宝商品规格到当前 PDD 商品的可复用映射。
|
||||||
func SaveSybMapping(db *sql.DB, sybID, optionKey, operator string) error {
|
func SaveSybMapping(db *sql.DB, sybID, optionKey, operator string) error {
|
||||||
tx, err := db.Begin()
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -89,8 +90,12 @@ func SaveSybMapping(db *sql.DB, sybID, optionKey, operator string) error {
|
|||||||
if context == nil {
|
if context == nil {
|
||||||
return fmt.Errorf("顺运宝明细不存在")
|
return fmt.Errorf("顺运宝明细不存在")
|
||||||
}
|
}
|
||||||
if context.Order.ShopeeSKUID == "" {
|
key, err := spec.SpecKey(context.Order.ProductSpec)
|
||||||
return fmt.Errorf("请先确认蝦皮规格")
|
if err != nil || context.Order.SpecKey == "" {
|
||||||
|
return fmt.Errorf("顺运宝未提供有效规格,不能保存映射")
|
||||||
|
}
|
||||||
|
if key != context.Order.SpecKey {
|
||||||
|
return fmt.Errorf("顺运宝规格身份键不一致,请联系维护者检查数据")
|
||||||
}
|
}
|
||||||
if context.PddGoodsID == "" || context.PddCollectStatus != string(model.CollectCollected) ||
|
if context.PddGoodsID == "" || context.PddCollectStatus != string(model.CollectCollected) ||
|
||||||
strings.TrimSpace(context.PddSkusJSON) == "" {
|
strings.TrimSpace(context.PddSkusJSON) == "" {
|
||||||
@@ -103,11 +108,11 @@ func SaveSybMapping(db *sql.DB, sybID, optionKey, operator string) error {
|
|||||||
if choice == nil {
|
if choice == nil {
|
||||||
return fmt.Errorf("所选 PDD 规格已不存在或不可购买,请刷新后重试")
|
return fmt.Errorf("所选 PDD 规格已不存在或不可购买,请刷新后重试")
|
||||||
}
|
}
|
||||||
if err := repository.UpsertSKUMapping(tx, model.SKUMapping{
|
if err := repository.UpsertSpecMapping(tx, model.SpecMapping{
|
||||||
ShopeeSKUID: context.Order.ShopeeSKUID, PddGoodsID: context.PddGoodsID,
|
ShopeeGoodsID: context.Order.ShopeeGoodsID, SpecKey: key,
|
||||||
|
SpecRaw: context.Order.ProductSpec, PddGoodsID: context.PddGoodsID,
|
||||||
PddOptionKey: choice.Key, PddOptions: choice.OptionsJSON,
|
PddOptionKey: choice.Key, PddOptions: choice.OptionsJSON,
|
||||||
GoodsID: context.Order.ShopeeGoodsID, MappedAt: model.NowISO(),
|
MappedAt: model.NowISO(), MappedBy: strings.TrimSpace(operator),
|
||||||
MappedBy: strings.TrimSpace(operator),
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -179,7 +184,7 @@ func CreatePurchaseTasks(db *sql.DB, actor *model.User, requests []PurchaseTaskR
|
|||||||
if err := repository.InsertPurchaseTask(tx, model.Task{
|
if err := repository.InsertPurchaseTask(tx, model.Task{
|
||||||
TaskID: newPurchaseTaskID(), AssignedClient: clientID,
|
TaskID: newPurchaseTaskID(), AssignedClient: clientID,
|
||||||
SybID: context.Order.SybID, OrderNo: context.Order.OrderNo,
|
SybID: context.Order.SybID, OrderNo: context.Order.OrderNo,
|
||||||
GoodsID: context.Order.ShopeeGoodsID, ShopeeSKUID: context.Order.ShopeeSKUID,
|
GoodsID: context.Order.ShopeeGoodsID,
|
||||||
PddGoodsURL: context.PddGoodsURL, PddGoodsID: context.PddGoodsID,
|
PddGoodsURL: context.PddGoodsURL, PddGoodsID: context.PddGoodsID,
|
||||||
PddOptions: optionsJSON, Quantity: context.Order.Quantity,
|
PddOptions: optionsJSON, Quantity: context.Order.Quantity,
|
||||||
MaxPriceCent: request.MaxPriceCent,
|
MaxPriceCent: request.MaxPriceCent,
|
||||||
@@ -202,8 +207,8 @@ func validatePurchaseRequest(q repository.Execer, request PurchaseTaskRequest) (
|
|||||||
if context == nil {
|
if context == nil {
|
||||||
return "顺运宝明细不存在", nil, "", nil
|
return "顺运宝明细不存在", nil, "", nil
|
||||||
}
|
}
|
||||||
if context.Order.ShopeeSKUID == "" {
|
if context.Order.SpecKey == "" {
|
||||||
return "蝦皮规格未确认", context, "", nil
|
return "顺运宝未提供规格", context, "", nil
|
||||||
}
|
}
|
||||||
if context.PddGoodsID == "" || context.PddGoodsURL == "" {
|
if context.PddGoodsID == "" || context.PddGoodsURL == "" {
|
||||||
return "未关联 PDD 商品", context, "", nil
|
return "未关联 PDD 商品", context, "", nil
|
||||||
|
|||||||
@@ -22,18 +22,11 @@ const collectedThreeDimensions = `{
|
|||||||
|
|
||||||
func seedPurchasableWorkflow(t *testing.T, db *sql.DB, sybID string) string {
|
func seedPurchasableWorkflow(t *testing.T, db *sql.DB, sybID string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := repository.UpsertShopeeProduct(db, "SP-1", "测试上衣", "正常", ""); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
seedWorkflowSKU(t, db, "SKU-1", "SP-1", "黑色,M")
|
|
||||||
order := seedWorkflowOrder(t, db, sybID, "SP-1", "黑色,M")
|
order := seedWorkflowOrder(t, db, sybID, "SP-1", "黑色,M")
|
||||||
order.PriceTwdCent = 999900
|
order.PriceTwdCent = 999900
|
||||||
if _, err := repository.UpsertSybOrder(db, order); err != nil {
|
if _, err := repository.UpsertSybOrder(db, order); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := AssignSybShopeeSKU(db, sybID, "SKU-1"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := AssociateShopeePdd(db, "SP-1", pddURLA, false); err != nil {
|
if _, err := AssociateShopeePdd(db, "SP-1", pddURLA, false); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -62,7 +55,7 @@ func TestSybMapping_动态维度保存并复用(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !detail.MappingValid {
|
if !detail.MappingValid {
|
||||||
t.Fatal("保存后相同蝦皮 SKU + 当前 PDD 商品应自动复用")
|
t.Fatal("保存后相同蝦皮商品规格 + 当前 PDD 商品应自动复用")
|
||||||
}
|
}
|
||||||
list, err := ListSybOrdersView(db, "", SybStagePurchaseReady, 1)
|
list, err := ListSybOrdersView(db, "", SybStagePurchaseReady, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -73,6 +66,35 @@ func TestSybMapping_动态维度保存并复用(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSybMapping_同商品同规格第二条明细自动命中(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
key := seedPurchasableWorkflow(t, db, "SYB-1")
|
||||||
|
if err := SaveSybMapping(db, "SYB-1", key, "USR-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seedWorkflowOrder(t, db, "SYB-2", "SP-1", " 黑色,M ")
|
||||||
|
detail, err := GetSybProcessingDetail(db, "SYB-2")
|
||||||
|
if err != nil || detail == nil || !detail.MappingValid || detail.Stage != SybStagePurchaseReady {
|
||||||
|
t.Fatalf("第二条相同规格应自动命中: detail=%+v err=%v", detail, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSybMapping_解析失败原文仍可保存且空规格被阻断(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
key := seedPurchasableWorkflow(t, db, "SYB-BASE")
|
||||||
|
seedWorkflowOrder(t, db, "SYB-DIRTY", "SP-1", "紅色,3XL建議80-90公斤】")
|
||||||
|
if err := SaveSybMapping(db, "SYB-DIRTY", key, "USR-1"); err != nil {
|
||||||
|
t.Fatalf("规格解析失败不应影响身份键映射: %v", err)
|
||||||
|
}
|
||||||
|
seedWorkflowOrder(t, db, "SYB-EMPTY", "SP-1", "")
|
||||||
|
if err := SaveSybMapping(db, "SYB-EMPTY", key, "USR-1"); err == nil {
|
||||||
|
t.Fatal("空规格不得保存映射")
|
||||||
|
}
|
||||||
|
if _, err := AssociateSybPdd(db, "SYB-EMPTY", pddURLA, false); err == nil {
|
||||||
|
t.Fatal("空规格不得进入采购关联流程")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSybMapping_换品和选项消失都会失效(t *testing.T) {
|
func TestSybMapping_换品和选项消失都会失效(t *testing.T) {
|
||||||
t.Run("换 PDD 商品隔离旧映射", func(t *testing.T) {
|
t.Run("换 PDD 商品隔离旧映射", func(t *testing.T) {
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
@@ -86,6 +108,13 @@ func TestSybMapping_换品和选项消失都会失效(t *testing.T) {
|
|||||||
if detail.MappingValid {
|
if detail.MappingValid {
|
||||||
t.Fatal("更换 PDD 商品后不得复用旧映射")
|
t.Fatal("更换 PDD 商品后不得复用旧映射")
|
||||||
}
|
}
|
||||||
|
if _, err := AssociateShopeePdd(db, "SP-1", pddURLA, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
detail, _ = GetSybProcessingDetail(db, "SYB-1")
|
||||||
|
if !detail.MappingValid {
|
||||||
|
t.Fatal("换回原 PDD 商品后应复用原映射")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("最新采集结果删除原选项", func(t *testing.T) {
|
t.Run("最新采集结果删除原选项", func(t *testing.T) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type ShopeeProductView struct {
|
|||||||
// `[必须]` > 0 时整行标黄(沿用 .row-warn)——聚合成商品级之后,
|
// `[必须]` > 0 时整行标黄(沿用 .row-warn)——聚合成商品级之后,
|
||||||
// "部分失败"的商品数字看起来完全正常,坏数据会被吃掉,见 #41。
|
// "部分失败"的商品数字看起来完全正常,坏数据会被吃掉,见 #41。
|
||||||
PendingCount int
|
PendingCount int
|
||||||
|
SourceText string
|
||||||
|
|
||||||
// PddURL 为空且 PddMissing 为 true 时,模板显示"未填写"并标红——
|
// PddURL 为空且 PddMissing 为 true 时,模板显示"未填写"并标红——
|
||||||
// 这是最需要操作员注意的状态,见 §4.2。
|
// 这是最需要操作员注意的状态,见 §4.2。
|
||||||
@@ -108,6 +109,7 @@ func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (*
|
|||||||
SizeCount: r.SizeCount,
|
SizeCount: r.SizeCount,
|
||||||
SKUCount: r.SKUCount,
|
SKUCount: r.SKUCount,
|
||||||
PendingCount: r.PendingCount,
|
PendingCount: r.PendingCount,
|
||||||
|
SourceText: shopeeSourceText(r.Source),
|
||||||
UpdatedAt: formatLocalTime(r.UpdatedAt),
|
UpdatedAt: formatLocalTime(r.UpdatedAt),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +133,13 @@ func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (*
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shopeeSourceText(source string) string {
|
||||||
|
if source == "syb" {
|
||||||
|
return "顺运宝骨架"
|
||||||
|
}
|
||||||
|
return "蝦皮报表"
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 状态筛选 ----------
|
// ---------- 状态筛选 ----------
|
||||||
|
|
||||||
// shopeeStatusTexts 是状态筛选下拉框每个取值对应的中文文字,
|
// shopeeStatusTexts 是状态筛选下拉框每个取值对应的中文文字,
|
||||||
|
|||||||
@@ -75,8 +75,11 @@ func AssociateSybPdd(db *sql.DB, sybID, rawURL string, confirmReplace bool) (str
|
|||||||
if context == nil {
|
if context == nil {
|
||||||
return "", fmt.Errorf("顺运宝明细不存在")
|
return "", fmt.Errorf("顺运宝明细不存在")
|
||||||
}
|
}
|
||||||
if context.Order.ShopeeSKUID == "" {
|
if context.Order.ShopeeGoodsID == "" {
|
||||||
return "", fmt.Errorf("请先确认蝦皮规格")
|
return "", fmt.Errorf("顺运宝未提供蝦皮商品 ID")
|
||||||
|
}
|
||||||
|
if context.Order.SpecKey == "" {
|
||||||
|
return "", fmt.Errorf("顺运宝未提供规格,不能进入采购流程")
|
||||||
}
|
}
|
||||||
return AssociateShopeePdd(db, context.Order.ShopeeGoodsID, rawURL, confirmReplace)
|
return AssociateShopeePdd(db, context.Order.ShopeeGoodsID, rawURL, confirmReplace)
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-133
@@ -5,9 +5,7 @@
|
|||||||
// 那些分别在 handler/web/others.go 和 repository/syb.go。
|
// 那些分别在 handler/web/others.go 和 repository/syb.go。
|
||||||
//
|
//
|
||||||
// 接口契约见 docs/admin/08-顺运宝接口.md,工单见 #46。三条最容易出事的规则:
|
// 接口契约见 docs/admin/08-顺运宝接口.md,工单见 #46。三条最容易出事的规则:
|
||||||
// 1. shopee_sku_id 绝不能被同步写入/覆盖——这条已经在
|
// 1. shopee_sku_id 是历史兼容列,同步不读写;规格身份由 product_spec/spec_key 表达。
|
||||||
// repository.UpsertSybOrder 的 SQL 层面保证,本文件不需要、
|
|
||||||
// 也不允许再传一份"新的" shopee_sku_id 进去。
|
|
||||||
// 2. 增量必须从"上次同步日期当天"重新拉,不是第二天,见 syncDateRange。
|
// 2. 增量必须从"上次同步日期当天"重新拉,不是第二天,见 syncDateRange。
|
||||||
// 3. 中途失败不更新 last_synced_at,见 RunSybSync 的最后一步。
|
// 3. 中途失败不更新 last_synced_at,见 RunSybSync 的最后一步。
|
||||||
package service
|
package service
|
||||||
@@ -772,21 +770,16 @@ func writeStockDetail(db *sql.DB, baseURL string, stockRow syb.StockRow, detail
|
|||||||
Title: item.ProductTitle,
|
Title: item.ProductTitle,
|
||||||
ProductSpec: item.ProductSpec,
|
ProductSpec: item.ProductSpec,
|
||||||
ShopeeGoodsID: strconv.FormatInt(item.ProductID, 10),
|
ShopeeGoodsID: strconv.FormatInt(item.ProductID, 10),
|
||||||
// `[必须]` 不设置 ShopeeSKUID——顺运宝没有这个值,
|
Quantity: item.ProductQty,
|
||||||
// repository.UpsertSybOrder 也不会用它覆盖已有的匹配结果。
|
PriceTwdCent: roundYuanToCent(item.ProductPrice),
|
||||||
Quantity: item.ProductQty,
|
ImageURL: imageURLFromThumb(baseURL, item.ProductThumb),
|
||||||
PriceTwdCent: roundYuanToCent(item.ProductPrice),
|
SybData: sybData,
|
||||||
ImageURL: imageURLFromThumb(baseURL, item.ProductThumb),
|
|
||||||
SybData: sybData,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
created, err := repository.UpsertSybOrder(tx, order)
|
created, err := repository.UpsertSybOrder(tx, order)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := AutoResolveSybShopeeSKU(tx, order); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if created {
|
if created {
|
||||||
report.Created++
|
report.Created++
|
||||||
} else {
|
} else {
|
||||||
@@ -921,7 +914,6 @@ type SybOrderView struct {
|
|||||||
Title string
|
Title string
|
||||||
ProductSpec string
|
ProductSpec string
|
||||||
ShopeeGoodsID string
|
ShopeeGoodsID string
|
||||||
ShopeeSKUID string
|
|
||||||
Quantity int
|
Quantity int
|
||||||
PriceText string // "NT$239.00",和人民币价格一眼分得清
|
PriceText string // "NT$239.00",和人民币价格一眼分得清
|
||||||
ImageURL string
|
ImageURL string
|
||||||
@@ -936,8 +928,7 @@ type SybOrderView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SybStageMissingShopee = "missing_shopee"
|
SybStageSpecMissing = "spec_missing"
|
||||||
SybStageSKUPending = "sku_pending"
|
|
||||||
SybStagePddMissing = "pdd_missing"
|
SybStagePddMissing = "pdd_missing"
|
||||||
SybStagePddPending = "pdd_pending"
|
SybStagePddPending = "pdd_pending"
|
||||||
SybStagePddCollecting = "pdd_collecting"
|
SybStagePddCollecting = "pdd_collecting"
|
||||||
@@ -957,8 +948,7 @@ type SybStageOption struct {
|
|||||||
func SybStageOptions() []SybStageOption {
|
func SybStageOptions() []SybStageOption {
|
||||||
return []SybStageOption{
|
return []SybStageOption{
|
||||||
{Value: "", Text: "全部"},
|
{Value: "", Text: "全部"},
|
||||||
{Value: SybStageMissingShopee, Text: "未找到蝦皮商品"},
|
{Value: SybStageSpecMissing, Text: "采购数据异常:顺运宝未提供规格"},
|
||||||
{Value: SybStageSKUPending, Text: "待确认蝦皮规格"},
|
|
||||||
{Value: SybStagePddMissing, Text: "未关联 PDD"},
|
{Value: SybStagePddMissing, Text: "未关联 PDD"},
|
||||||
{Value: SybStagePddPending, Text: "PDD 待采集"},
|
{Value: SybStagePddPending, Text: "PDD 待采集"},
|
||||||
{Value: SybStagePddCollecting, Text: "PDD 采集中"},
|
{Value: SybStagePddCollecting, Text: "PDD 采集中"},
|
||||||
@@ -990,12 +980,10 @@ func SybStageLabel(stage string) string {
|
|||||||
|
|
||||||
func sybStageFor(c repository.SybOrderContext) (stage, text, help, action string) {
|
func sybStageFor(c repository.SybOrderContext) (stage, text, help, action string) {
|
||||||
switch {
|
switch {
|
||||||
case !c.ShopeeExists:
|
case c.Order.SpecKey == "":
|
||||||
return SybStageMissingShopee, "未找到蝦皮商品", "请先导入或补充该蝦皮商品。", "查看"
|
return SybStageSpecMissing, "采购数据异常:顺运宝未提供规格", "缺少规格原文,不能匹配或创建采购任务。", "核对数据"
|
||||||
case c.Order.ShopeeSKUID == "":
|
|
||||||
return SybStageSKUPending, "待确认蝦皮规格", "请选择与顺运宝规格原文对应的蝦皮 SKU。", "确认规格"
|
|
||||||
case c.PddGoodsID == "" || c.PddCollectStatus == "":
|
case c.PddGoodsID == "" || c.PddCollectStatus == "":
|
||||||
return SybStagePddMissing, "未关联 PDD", "蝦皮规格已确认,下一步关联采购商品。", "关联 PDD"
|
return SybStagePddMissing, "未关联 PDD", "下一步关联采购商品。", "关联 PDD"
|
||||||
case c.PddCollectStatus == string(model.CollectPending):
|
case c.PddCollectStatus == string(model.CollectPending):
|
||||||
return SybStagePddPending, "PDD 待采集", "已关联 PDD 商品,尚未创建采集任务。", "创建采集"
|
return SybStagePddPending, "PDD 待采集", "已关联 PDD 商品,尚未创建采集任务。", "创建采集"
|
||||||
case c.PddCollectStatus == string(model.CollectCollecting):
|
case c.PddCollectStatus == string(model.CollectCollecting):
|
||||||
@@ -1030,9 +1018,8 @@ type SybListResult struct {
|
|||||||
|
|
||||||
// ListSybOrdersView 按筛选条件分页查货运单明细列表,翻成界面文字。
|
// ListSybOrdersView 按筛选条件分页查货运单明细列表,翻成界面文字。
|
||||||
//
|
//
|
||||||
// `[必须]` 处理阶段由顺运宝明细、蝦皮 SKU、PDD 关联和采集状态实时推导,
|
// `[必须]` 处理阶段由顺运宝规格键、PDD 关联、采集状态和规格映射实时推导,
|
||||||
// 不额外保存一份容易过期的状态字段。shopee_sku_id 只表示已经确认了
|
// 不额外保存一份容易过期的状态字段。
|
||||||
// 顺运宝规格对应的蝦皮 SKU,不代表已经完成 PDD 规格映射。
|
|
||||||
func ListSybOrdersView(db *sql.DB, keyword, stage string, page int) (*SybListResult, error) {
|
func ListSybOrdersView(db *sql.DB, keyword, stage string, page int) (*SybListResult, error) {
|
||||||
stage = ParseSybStage(stage)
|
stage = ParseSybStage(stage)
|
||||||
if stage == SybStageMappingPending || stage == SybStagePurchaseReady ||
|
if stage == SybStageMappingPending || stage == SybStagePurchaseReady ||
|
||||||
@@ -1074,7 +1061,7 @@ func ListSybOrdersView(db *sql.DB, keyword, stage string, page int) (*SybListRes
|
|||||||
}
|
}
|
||||||
|
|
||||||
// listAdvancedSybStage 在 Service 解析最新 skus_json 后筛选映射相关阶段。
|
// listAdvancedSybStage 在 Service 解析最新 skus_json 后筛选映射相关阶段。
|
||||||
// 这三个阶段不能只靠 SQL 判断:同一个 option key 是否仍存在,需要走唯一的
|
// 这四个阶段不能只靠 SQL 判断:同一个 option key 是否仍存在,需要走唯一的
|
||||||
// OptionKey 规范化逻辑。当前同步量是百到千级,先保证采购判断正确;普通列表
|
// OptionKey 规范化逻辑。当前同步量是百到千级,先保证采购判断正确;普通列表
|
||||||
// 和其余阶段仍在数据库分页。
|
// 和其余阶段仍在数据库分页。
|
||||||
func listAdvancedSybStage(db *sql.DB, keyword, stage string, page int) (*SybListResult, error) {
|
func listAdvancedSybStage(db *sql.DB, keyword, stage string, page int) (*SybListResult, error) {
|
||||||
@@ -1118,7 +1105,7 @@ func sybOrderViewFor(context repository.SybOrderContext) SybOrderView {
|
|||||||
o := context.Order
|
o := context.Order
|
||||||
v := SybOrderView{SybID: o.SybID, OrderNo: o.OrderNo, Title: o.Title,
|
v := SybOrderView{SybID: o.SybID, OrderNo: o.OrderNo, Title: o.Title,
|
||||||
ProductSpec: o.ProductSpec, ShopeeGoodsID: o.ShopeeGoodsID,
|
ProductSpec: o.ProductSpec, ShopeeGoodsID: o.ShopeeGoodsID,
|
||||||
ShopeeSKUID: o.ShopeeSKUID, Quantity: o.Quantity, ImageURL: o.ImageURL,
|
Quantity: o.Quantity, ImageURL: o.ImageURL,
|
||||||
UpdatedAt: formatLocalTime(o.UpdatedAt)}
|
UpdatedAt: formatLocalTime(o.UpdatedAt)}
|
||||||
v.Stage, v.StageText, v.StageHelp, v.ActionText = sybStageFor(context)
|
v.Stage, v.StageText, v.StageHelp, v.ActionText = sybStageFor(context)
|
||||||
v.CanPurchase = v.Stage == SybStagePurchaseReady
|
v.CanPurchase = v.Stage == SybStagePurchaseReady
|
||||||
@@ -1149,89 +1136,6 @@ func defaultMaxPrice(context repository.SybOrderContext) string {
|
|||||||
return fmt.Sprintf("%d.%02d", choice.PriceCent/100, choice.PriceCent%100)
|
return fmt.Sprintf("%d.%02d", choice.PriceCent/100, choice.PriceCent%100)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 是顺运宝“下一步”弹窗第一阶段需要的上下文。
|
// SybProcessingDetail 是顺运宝“下一步”弹窗第一阶段需要的上下文。
|
||||||
type SybProcessingDetail struct {
|
type SybProcessingDetail struct {
|
||||||
SybID string
|
SybID string
|
||||||
@@ -1239,12 +1143,10 @@ type SybProcessingDetail struct {
|
|||||||
Title string
|
Title string
|
||||||
ProductSpec string
|
ProductSpec string
|
||||||
ShopeeGoodsID string
|
ShopeeGoodsID string
|
||||||
ShopeeSKUID string
|
|
||||||
Quantity int
|
Quantity int
|
||||||
Stage string
|
Stage string
|
||||||
StageText string
|
StageText string
|
||||||
StageHelp string
|
StageHelp string
|
||||||
Candidates []SybSKUCandidateView
|
|
||||||
ShopeeExists bool
|
ShopeeExists bool
|
||||||
PddGoodsID string
|
PddGoodsID string
|
||||||
PddURL string
|
PddURL string
|
||||||
@@ -1265,8 +1167,8 @@ func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, err
|
|||||||
o := context.Order
|
o := context.Order
|
||||||
d := &SybProcessingDetail{
|
d := &SybProcessingDetail{
|
||||||
SybID: o.SybID, OrderNo: o.OrderNo, Title: o.Title, ProductSpec: o.ProductSpec,
|
SybID: o.SybID, OrderNo: o.OrderNo, Title: o.Title, ProductSpec: o.ProductSpec,
|
||||||
ShopeeGoodsID: o.ShopeeGoodsID, ShopeeSKUID: o.ShopeeSKUID,
|
ShopeeGoodsID: o.ShopeeGoodsID,
|
||||||
Quantity: o.Quantity, ShopeeExists: context.ShopeeExists,
|
Quantity: o.Quantity, ShopeeExists: context.ShopeeExists,
|
||||||
PddGoodsID: context.PddGoodsID, PddURL: context.PddGoodsURL,
|
PddGoodsID: context.PddGoodsID, PddURL: context.PddGoodsURL,
|
||||||
CollectStatus: context.PddCollectStatus, CollectMsg: context.PddCollectMsg,
|
CollectStatus: context.PddCollectStatus, CollectMsg: context.PddCollectMsg,
|
||||||
}
|
}
|
||||||
@@ -1288,25 +1190,6 @@ func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
d.Stage, d.StageText, d.StageHelp, _ = sybStageFor(*context)
|
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
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import (
|
|||||||
"cmautobuy/admin/repository"
|
"cmautobuy/admin/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
func seedWorkflowOrder(t *testing.T, db *sql.DB, sybID, goodsID, spec string) model.SybOrder {
|
func seedWorkflowOrder(t *testing.T, db *sql.DB, sybID, goodsID, rawSpec string) model.SybOrder {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
order := model.SybOrder{
|
order := model.SybOrder{
|
||||||
SybID: sybID, OrderNo: "ORDER-" + sybID, Title: "测试商品",
|
SybID: sybID, OrderNo: "ORDER-" + sybID, Title: "测试商品",
|
||||||
ProductSpec: spec, ShopeeGoodsID: goodsID, Quantity: 1, SybData: `{}`,
|
ProductSpec: rawSpec, ShopeeGoodsID: goodsID, Quantity: 1, SybData: `{}`,
|
||||||
}
|
}
|
||||||
if _, err := repository.UpsertSybOrder(db, order); err != nil {
|
if _, err := repository.UpsertSybOrder(db, order); err != nil {
|
||||||
t.Fatalf("准备顺运宝明细失败: %v", err)
|
t.Fatalf("准备顺运宝明细失败: %v", err)
|
||||||
@@ -20,106 +20,41 @@ func seedWorkflowOrder(t *testing.T, db *sql.DB, sybID, goodsID, spec string) mo
|
|||||||
return order
|
return order
|
||||||
}
|
}
|
||||||
|
|
||||||
func seedWorkflowSKU(t *testing.T, db *sql.DB, skuID, goodsID, spec string) {
|
func TestUpsertSybOrder_补建骨架并由报表提升来源(t *testing.T) {
|
||||||
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)
|
db := newSyncTestDB(t)
|
||||||
for _, goodsID := range []string{"SP-A", "SP-B"} {
|
seedWorkflowOrder(t, db, "SYB-1", "SP-1", "黑色, M")
|
||||||
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 {
|
product, err := repository.GetShopeeProductByGoodsID(db, "SP-1")
|
||||||
t.Fatal("不得把其他蝦皮商品的 SKU 保存到当前顺运宝明细")
|
if err != nil || product == nil || product.Source != "syb" || product.Title != "测试商品" {
|
||||||
|
t.Fatalf("顺运宝骨架不正确: product=%+v err=%v", product, err)
|
||||||
}
|
}
|
||||||
if got := readOrderSKU(t, db, "SYB-A"); got != "" {
|
context, err := repository.GetSybOrderContext(db, "SYB-1")
|
||||||
t.Fatalf("越权选择后不应写库,实际 %q", got)
|
if err != nil || context == nil || context.Order.SpecKey != "黑色, M" {
|
||||||
|
t.Fatalf("规格身份键不正确: context=%+v err=%v", context, err)
|
||||||
}
|
}
|
||||||
if err := AssignSybShopeeSKU(db, "SYB-A", "SKU-A"); err != nil {
|
|
||||||
t.Fatalf("同商品 SKU 应保存成功: %v", err)
|
if err := repository.UpsertShopeeProduct(db, "SP-1", "报表标题", "正常", "MAIN-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
product, err = repository.GetShopeeProductByGoodsID(db, "SP-1")
|
||||||
|
if err != nil || product.Source != "report" || product.Title != "报表标题" {
|
||||||
|
t.Fatalf("报表导入应提升骨架来源: product=%+v err=%v", product, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seedWorkflowOrder(t, db, "SYB-2", "SP-1", "白色,L")
|
||||||
|
product, _ = repository.GetShopeeProductByGoodsID(db, "SP-1")
|
||||||
|
if product.Source != "report" || product.Title != "报表标题" {
|
||||||
|
t.Fatalf("后续同步不得覆盖报表商品: %+v", product)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListSybOrdersView_阶段筛选区分蝦皮识别和PDD准备(t *testing.T) {
|
func TestListSybOrdersView_空规格阻断且普通规格直接进入PDD阶段(t *testing.T) {
|
||||||
db := newSyncTestDB(t)
|
db := newSyncTestDB(t)
|
||||||
seedWorkflowOrder(t, db, "SYB-MISSING", "NOT-IMPORTED", "黑色,M")
|
seedWorkflowOrder(t, db, "SYB-EMPTY", "SP-EMPTY", "")
|
||||||
if err := repository.UpsertShopeeProduct(db, "SP-READY", "商品", "", ""); err != nil {
|
seedWorkflowOrder(t, db, "SYB-PDD", "SP-PDD", "黑色,M")
|
||||||
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 }{
|
cases := []struct{ stage, wantID string }{
|
||||||
{SybStageMissingShopee, "SYB-MISSING"},
|
{SybStageSpecMissing, "SYB-EMPTY"},
|
||||||
{SybStageSKUPending, "SYB-SKU"},
|
|
||||||
{SybStagePddMissing, "SYB-PDD"},
|
{SybStagePddMissing, "SYB-PDD"},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -135,3 +70,43 @@ func TestListSybOrdersView_阶段筛选区分蝦皮识别和PDD准备(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSybStageFor_全部筛选阶段与逐行推导一致(t *testing.T) {
|
||||||
|
key, err := OptionKey(map[string]string{"color": "黑色", "size": "M码", "style": "常规"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ready := repository.SybOrderContext{
|
||||||
|
Order: model.SybOrder{SpecKey: "黑色,M", Quantity: 1},
|
||||||
|
PddGoodsID: "737116531267", PddCollectStatus: string(model.CollectCollected),
|
||||||
|
PddSkusJSON: collectedThreeDimensions, MappingOptionKey: key,
|
||||||
|
}
|
||||||
|
copyWith := func(change func(*repository.SybOrderContext)) repository.SybOrderContext {
|
||||||
|
value := ready
|
||||||
|
change(&value)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
want string
|
||||||
|
context repository.SybOrderContext
|
||||||
|
}{
|
||||||
|
{SybStageSpecMissing, copyWith(func(c *repository.SybOrderContext) { c.Order.SpecKey = "" })},
|
||||||
|
{SybStagePddMissing, copyWith(func(c *repository.SybOrderContext) { c.PddGoodsID, c.PddCollectStatus = "", "" })},
|
||||||
|
{SybStagePddPending, copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectPending) })},
|
||||||
|
{SybStagePddCollecting, copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectCollecting) })},
|
||||||
|
{SybStagePddFailed, copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectFailed) })},
|
||||||
|
{SybStageMappingPending, copyWith(func(c *repository.SybOrderContext) { c.MappingOptionKey = "" })},
|
||||||
|
{SybStageTaskCreated, copyWith(func(c *repository.SybOrderContext) { c.HasActiveTask = true })},
|
||||||
|
{SybStagePurchaseBlocked, copyWith(func(c *repository.SybOrderContext) { c.Order.Quantity = 0 })},
|
||||||
|
{SybStagePurchaseReady, ready},
|
||||||
|
}
|
||||||
|
if got := len(SybStageOptions()) - 1; got != len(cases) {
|
||||||
|
t.Fatalf("筛选项有 %d 个业务阶段,但测试只覆盖 %d 个", got, len(cases))
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
stage, text, _, action := sybStageFor(tc.context)
|
||||||
|
if stage != tc.want || text != SybStageLabel(tc.want) || action == "" {
|
||||||
|
t.Fatalf("阶段 %s 推导错误: stage=%s text=%q action=%q", tc.want, stage, text, action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Package spec 提供顺运宝规格身份键的唯一规范化实现。
|
||||||
|
package spec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxKeyRunes 是数据库 VARCHAR(191) 身份列允许的最大字符数。
|
||||||
|
const MaxKeyRunes = 191
|
||||||
|
|
||||||
|
// SpecKey 把顺运宝规格原文规范化成稳定身份键。
|
||||||
|
//
|
||||||
|
// 它只去掉首尾空白并把连续空白折叠为一个空格,不做颜色、尺码或繁简
|
||||||
|
// 推断。空规格和超过数据库列宽的规格返回错误,调用方不得截断。
|
||||||
|
func SpecKey(raw string) (string, error) {
|
||||||
|
key := strings.Join(strings.Fields(raw), " ")
|
||||||
|
if key == "" {
|
||||||
|
return "", fmt.Errorf("规格原文不能为空")
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(key) > MaxKeyRunes {
|
||||||
|
return "", fmt.Errorf("规格身份键超过 %d 个字符", MaxKeyRunes)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package spec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSpecKey_只折叠空白(t *testing.T) {
|
||||||
|
got, err := SpecKey(" 黑色,\tM【建議40-50公斤】 \n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "黑色, M【建議40-50公斤】" {
|
||||||
|
t.Fatalf("SpecKey=%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpecKey_不依赖规格解析(t *testing.T) {
|
||||||
|
got, err := SpecKey("紅色,3XL建議80-90公斤】")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "紅色,3XL建議80-90公斤】" {
|
||||||
|
t.Fatalf("SpecKey=%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpecKey_拒绝空值和超长值(t *testing.T) {
|
||||||
|
if _, err := SpecKey(" \t\n "); err == nil {
|
||||||
|
t.Fatal("空规格必须报错")
|
||||||
|
}
|
||||||
|
if _, err := SpecKey(strings.Repeat("规", MaxKeyRunes+1)); err == nil {
|
||||||
|
t.Fatal("超长规格必须报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@
|
|||||||
聚合成商品级之后,"部分失败"的行光看数字是正常的,
|
聚合成商品级之后,"部分失败"的行光看数字是正常的,
|
||||||
不加这一列坏数据就会被悄悄吃掉,见 #41。 */}}
|
不加这一列坏数据就会被悄悄吃掉,见 #41。 */}}
|
||||||
<th>待补</th>
|
<th>待补</th>
|
||||||
|
<th>来源</th>
|
||||||
<th>PDD 链接</th>
|
<th>PDD 链接</th>
|
||||||
<th>采集状态</th>
|
<th>采集状态</th>
|
||||||
<th>更新时间</th>
|
<th>更新时间</th>
|
||||||
@@ -69,13 +70,14 @@
|
|||||||
<td>{{.SizeCount}}</td>
|
<td>{{.SizeCount}}</td>
|
||||||
<td>{{.SKUCount}}</td>
|
<td>{{.SKUCount}}</td>
|
||||||
<td>{{.PendingCount}}</td>
|
<td>{{.PendingCount}}</td>
|
||||||
|
<td>{{.SourceText}}</td>
|
||||||
<td{{if .PddMissing}} class="missing"{{end}}>{{.PddURL}}</td>
|
<td{{if .PddMissing}} class="missing"{{end}}>{{.PddURL}}</td>
|
||||||
<td>{{.StatusText}}</td>
|
<td>{{.StatusText}}</td>
|
||||||
<td>{{.UpdatedAt}}</td>
|
<td>{{.UpdatedAt}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr class="empty">
|
<tr class="empty">
|
||||||
<td colspan="10">
|
<td colspan="11">
|
||||||
{{if .IsFiltered}}
|
{{if .IsFiltered}}
|
||||||
没有匹配的数据,换个商品 ID 或商品名称试试。
|
没有匹配的数据,换个商品 ID 或商品名称试试。
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -11,39 +11,13 @@
|
|||||||
<dl class="detail">
|
<dl class="detail">
|
||||||
<dt>明细 ID</dt><dd>{{.SybID}}</dd>
|
<dt>明细 ID</dt><dd>{{.SybID}}</dd>
|
||||||
<dt>商品</dt><dd>{{.Title}}</dd>
|
<dt>商品</dt><dd>{{.Title}}</dd>
|
||||||
<dt>顺运宝规格</dt><dd>{{.ProductSpec}}</dd>
|
<dt>顺运宝规格</dt><dd>{{if .ProductSpec}}{{.ProductSpec}}{{else}}—{{end}}</dd>
|
||||||
<dt>数量</dt><dd>{{.Quantity}}</dd>
|
<dt>数量</dt><dd>{{.Quantity}}</dd>
|
||||||
<dt>蝦皮商品 ID</dt><dd>{{.ShopeeGoodsID}}</dd>
|
<dt>蝦皮商品 ID</dt><dd>{{.ShopeeGoodsID}}</dd>
|
||||||
<dt>蝦皮规格 ID</dt><dd>{{if .ShopeeSKUID}}{{.ShopeeSKUID}}{{else}}—{{end}}</dd>
|
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{{if not .ShopeeExists}}
|
{{if eq .Stage "spec_missing"}}
|
||||||
<p class="missing">蝦皮数据中找不到商品 {{.ShopeeGoodsID}}。请先导入该商品,或核对顺运宝商品链接提取出的 ID。</p>
|
<p class="missing" role="alert">顺运宝没有提供规格原文,系统无法安全判断要采购的颜色和尺码。请先核对上游货运单数据。</p>
|
||||||
<p><a class="button-link" href="/shopee?goods_id={{.ShopeeGoodsID}}">到蝦皮数据查看</a></p>
|
|
||||||
{{else if and (not .ShopeeSKUID) (not .Candidates)}}
|
|
||||||
<p class="missing">这个蝦皮商品没有可选 SKU。请先在蝦皮数据中补充规格。</p>
|
|
||||||
<p><a class="button-link" href="/shopee?goods_id={{.ShopeeGoodsID}}">到蝦皮数据补充</a></p>
|
|
||||||
{{else if not .ShopeeSKUID}}
|
|
||||||
<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>
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<h3>关联采购商品</h3>
|
<h3>关联采购商品</h3>
|
||||||
<form method="post" action="/syb/associate-pdd" class="form-stack">
|
<form method="post" action="/syb/associate-pdd" class="form-stack">
|
||||||
@@ -87,14 +61,14 @@
|
|||||||
<input type="hidden" name="stage" value="{{$.StageFilter}}">
|
<input type="hidden" name="stage" value="{{$.StageFilter}}">
|
||||||
<input type="hidden" name="page" value="{{$.CurrentPage}}">
|
<input type="hidden" name="page" value="{{$.CurrentPage}}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="syb-pdd-option">蝦皮 {{.ProductSpec}} 对应的 PDD 规格</label>
|
<label for="syb-pdd-option">顺运宝 {{.ProductSpec}} 对应的 PDD 规格</label>
|
||||||
<select id="syb-pdd-option" name="pdd_option_key" required>
|
<select id="syb-pdd-option" name="pdd_option_key" required>
|
||||||
<option value="">请选择,不做相似规格猜测</option>
|
<option value="">请选择,不做相似规格猜测</option>
|
||||||
{{range .PddChoices}}
|
{{range .PddChoices}}
|
||||||
<option value="{{.Key}}" {{if .Selected}}selected{{end}}>{{.Label}} · {{.PriceText}}</option>
|
<option value="{{.Key}}" {{if .Selected}}selected{{end}}>{{.Label}} · {{.PriceText}}</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
<small>PDD 维度来自采集结果:{{range $i, $name := .PddDimensionNames}}{{if $i}} / {{end}}{{$name}}{{end}}。保存后相同蝦皮 SKU + 当前 PDD 商品会复用。</small>
|
<small>PDD 维度来自采集结果:{{range $i, $name := .PddDimensionNames}}{{if $i}} / {{end}}{{$name}}{{end}}。保存后相同蝦皮商品 + 顺运宝规格 + 当前 PDD 商品会复用。</small>
|
||||||
</div>
|
</div>
|
||||||
{{if and (not .MappingValid) .PddChoices}}<p class="missing">尚无有效映射,或原映射已不在最新采集结果中,请重新选择。</p>{{end}}
|
{{if and (not .MappingValid) .PddChoices}}<p class="missing">尚无有效映射,或原映射已不在最新采集结果中,请重新选择。</p>{{end}}
|
||||||
<div><button type="submit" class="primary">保存规格映射</button></div>
|
<div><button type="submit" class="primary">保存规格映射</button></div>
|
||||||
|
|||||||
@@ -91,7 +91,6 @@
|
|||||||
<th>商品标题</th>
|
<th>商品标题</th>
|
||||||
<th>规格</th>
|
<th>规格</th>
|
||||||
<th>蝦皮商品 ID</th>
|
<th>蝦皮商品 ID</th>
|
||||||
<th>规格 SKU</th>
|
|
||||||
<th>数量</th>
|
<th>数量</th>
|
||||||
<th>价格</th>
|
<th>价格</th>
|
||||||
<th>图片</th>
|
<th>图片</th>
|
||||||
@@ -112,7 +111,6 @@
|
|||||||
<td class="truncate" title="{{.Title}}">{{.Title}}</td>
|
<td class="truncate" title="{{.Title}}">{{.Title}}</td>
|
||||||
<td class="truncate" title="{{.ProductSpec}}">{{.ProductSpec}}</td>
|
<td class="truncate" title="{{.ProductSpec}}">{{.ProductSpec}}</td>
|
||||||
<td>{{.ShopeeGoodsID}}</td>
|
<td>{{.ShopeeGoodsID}}</td>
|
||||||
<td>{{if .ShopeeSKUID}}{{.ShopeeSKUID}}{{else}}—{{end}}</td>
|
|
||||||
<td>{{.Quantity}}</td>
|
<td>{{.Quantity}}</td>
|
||||||
<td>{{.PriceText}}</td>
|
<td>{{.PriceText}}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -124,7 +122,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr class="empty">
|
<tr class="empty">
|
||||||
<td colspan="13">
|
<td colspan="12">
|
||||||
{{if .IsFiltered}}
|
{{if .IsFiltered}}
|
||||||
当前筛选条件下没有货运单明细。<br>
|
当前筛选条件下没有货运单明细。<br>
|
||||||
<small>换个订单号或清空搜索词再试。<a href="/syb">查看全部</a></small>
|
<small>换个订单号或清空搜索词再试。<a href="/syb">查看全部</a></small>
|
||||||
|
|||||||
@@ -196,11 +196,10 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
|||||||
| 订单号 | |
|
| 订单号 | |
|
||||||
| 商品标题 | |
|
| 商品标题 | |
|
||||||
| 蝦皮商品 ID | 关联蝦皮数据模块 |
|
| 蝦皮商品 ID | 关联蝦皮数据模块 |
|
||||||
| 规格 SKU | 蝦皮的规格编号 |
|
|
||||||
| 数量 | |
|
| 数量 | |
|
||||||
| 价格 | 蝦皮售价,**台币分**,见 §7 |
|
| 价格 | 蝦皮售价,**台币分**,见 §7 |
|
||||||
| 图片 | 存 URL,表格里显示缩略图 |
|
| 图片 | 存 URL,表格里显示缩略图 |
|
||||||
| 处理阶段 | 未找到蝦皮商品 / 待确认蝦皮规格 / 未关联 PDD / 待采集 / 采集中 / 采集失败 / 规格待匹配 / 可采购 / 采购数据异常 / 已创建任务 |
|
| 处理阶段 | 采购数据异常(顺运宝未提供规格)/ 未关联 PDD / 待采集 / 采集中 / 采集失败 / 规格待匹配 / 可采购 / 已创建任务 |
|
||||||
| 下一步 | 每行只显示当前最需要完成的一个操作 |
|
| 下一步 | 每行只显示当前最需要完成的一个操作 |
|
||||||
| 更新时间 | |
|
| 更新时间 | |
|
||||||
|
|
||||||
@@ -208,15 +207,16 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
|||||||
|
|
||||||
**双击行或点击下一步打开处理弹窗:**
|
**双击行或点击下一步打开处理弹窗:**
|
||||||
|
|
||||||
- 顺运宝商品 ID 能找到蝦皮商品后,先确认蝦皮 SKU。规格原文完全一致,或解析后的颜色尺码只有一个唯一候选时可以自动确认;零候选和多候选必须人工选择,不能猜。
|
- 顺运宝的 `product_spec` 是匹配主链路的真实来源;系统不再要求先确认蝦皮 SKU。
|
||||||
- `shopee_sku_id` 只表示“蝦皮规格已确认”,不等于 PDD 规格已经匹配。完整处理阶段还要检查当前 PDD 关联、采集状态和 `sku_mappings`。
|
- “规格身份键” `spec_key` 只对顺运宝规格原文去除首尾空白、折叠连续空白,用于判断是否是同一条规格;颜色、尺寸和建议的解析只用于显示与推荐,不改变身份。
|
||||||
- 蝦皮 SKU 确认后,可在同一弹窗填写完整 PDD 链接并创建采集任务;链接关联规则与蝦皮详情完全一致。
|
- 找到蝦皮商品后,可在同一弹窗填写完整 PDD 链接并创建采集任务;链接关联规则与蝦皮详情完全一致。
|
||||||
|
|
||||||
- 显示蝦皮规格原文和当前 PDD 商品 `skus_json` 里的动态规格组合与人民币价格;
|
- 显示蝦皮规格原文和当前 PDD 商品 `skus_json` 里的动态规格组合与人民币价格;
|
||||||
- PDD 维度来自采集结果的 `dimensions`,不得写死成颜色、尺码两个维度;
|
- PDD 维度来自采集结果的 `dimensions`,不得写死成颜色、尺码两个维度;
|
||||||
- 操作员选好对应关系,点保存;
|
- 操作员选好对应关系,点保存;
|
||||||
- `[必须]` 保存的是**可复用的 SKU 映射**,不是这一张订单的临时数据。
|
- `[必须]` 保存的是**可复用的商品规格映射**,键为
|
||||||
下次遇到同一个蝦皮 SKU 自动带出,操作员只需确认。
|
`(蝦皮商品 ID, spec_key, PDD 商品 ID)`,不是这一张订单的临时数据。
|
||||||
|
下次遇到同商品、同规格和同 PDD 商品时自动带出。
|
||||||
- `[必须]` 该商品尚未采集(`skus_json` 为空)时,弹窗直接展示关联和创建采集任务入口,
|
- `[必须]` 该商品尚未采集(`skus_json` 为空)时,弹窗直接展示关联和创建采集任务入口,
|
||||||
而不是显示一个空列表让人困惑。
|
而不是显示一个空列表让人困惑。
|
||||||
|
|
||||||
@@ -333,7 +333,7 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
|||||||
|
|
||||||
1. 该蝦皮商品已填 PDD 链接;
|
1. 该蝦皮商品已填 PDD 链接;
|
||||||
2. 当前 PDD 商品已采集成功(`skus_json` 非空);
|
2. 当前 PDD 商品已采集成功(`skus_json` 非空);
|
||||||
3. 该蝦皮 SKU 已有 PDD 规格映射;
|
3. 该顺运宝规格非空,且已有当前 PDD 商品的规格映射;
|
||||||
4. 数量大于 0;
|
4. 数量大于 0;
|
||||||
5. **价格上限已填且大于 0** —— 默认从当前映射的 PDD 规格价格带出,操作员可改,但不允许为空;
|
5. **价格上限已填且大于 0** —— 默认从当前映射的 PDD 规格价格带出,操作员可改,但不允许为空;
|
||||||
6. 已选择分配的客户端;
|
6. 已选择分配的客户端;
|
||||||
@@ -470,9 +470,8 @@ MVP 之后:
|
|||||||
- **采集任务不指定客户端**,谁领到算谁的;采购任务仍可分配,也允许留空。
|
- **采集任务不指定客户端**,谁领到算谁的;采购任务仍可分配,也允许留空。
|
||||||
- 任务**分配给指定客户端**,Client 只领分给自己的。
|
- 任务**分配给指定客户端**,Client 只领分给自己的。
|
||||||
- **不加心跳接口**;设置页显式登记,领取接口保留兼容登记,在线状态由最近活动时间派生。
|
- **不加心跳接口**;设置页显式登记,领取接口保留兼容登记,在线状态由最近活动时间派生。
|
||||||
- SKU 映射**独立成表且可复用**,同一蝦皮 SKU 只人工匹配一次。
|
- 规格映射**独立成表且可复用**,按 `(蝦皮商品 ID, spec_key, PDD 商品 ID)` 隔离;换 PDD 商品不会误用旧映射,换回时可继续复用。
|
||||||
- 货运单的"规格 SKU"**直接对应蝦皮 `商品規格ID`**,不需要转换。
|
- 货运单的规格原文来自顺运宝;不再依赖蝦皮 `商品規格ID` 才能关联 PDD 和建立规格映射。
|
||||||
但**不加外键**——本地查不到该 SKU 是常态,见 [03 数据模型](03-data-model.md) §4。
|
|
||||||
- Admin 不提供固定默认密码;第一次启动由用户创建第一个管理员,之后初始化入口永久关闭。
|
- Admin 不提供固定默认密码;第一次启动由用户创建第一个管理员,之后初始化入口永久关闭。
|
||||||
- 账号只有 `admin`(管理员)和 `purchaser`(采购员)两种固定角色;采购员不能管理用户。
|
- 账号只有 `admin`(管理员)和 `purchaser`(采购员)两种固定角色;采购员不能管理用户。
|
||||||
- Web 登录只保护 HTML 页面,`/api/v1/client/*` 不使用网页登录 Session,现有 Client 行为保持不变。
|
- Web 登录只保护 HTML 页面,`/api/v1/client/*` 不使用网页登录 Session,现有 Client 行为保持不变。
|
||||||
|
|||||||
+53
-47
@@ -128,6 +128,12 @@ SQLite 同一时刻只允许一个写事务,连接放太开会互相抢锁、
|
|||||||
错误要等操作员点到那个页面才暴露,如果那是个写操作页面,暴露出来的就不是报错
|
错误要等操作员点到那个页面才暴露,如果那是个写操作页面,暴露出来的就不是报错
|
||||||
而是写坏数据。自检只查表名,不逐列校验:够抓住"迁移没跑到"这一类问题,代价也低。
|
而是写坏数据。自检只查表名,不逐列校验:够抓住"迁移没跑到"这一类问题,代价也低。
|
||||||
|
|
||||||
|
MySQL 迁移 v3(工单 #88)是追加式迁移:新增 `spec_mappings`,为 `syb_orders`
|
||||||
|
增加 `spec_key`,为 `shopee_products` 增加 `source`。迁移使用稳定 `syb_id`
|
||||||
|
游标分批回填,先完整备份旧 `sku_mappings` 为 `sku_mappings_v3_backup`,
|
||||||
|
再转换能找到顺运宝商品与规格的映射。复跑、字段已建但约束未建等中断状态必须自动收敛;
|
||||||
|
启动自检除表名外还校验 v3 主键、长度、二进制排序规则和 `source` 取值约束。
|
||||||
|
|
||||||
## 3. 蝦皮数据
|
## 3. 蝦皮数据
|
||||||
|
|
||||||
蝦皮报表**一个文件里混了两层数据**,所以拆成两张表。
|
蝦皮报表**一个文件里混了两层数据**,所以拆成两张表。
|
||||||
@@ -140,6 +146,7 @@ CREATE TABLE shopee_products (
|
|||||||
title TEXT NOT NULL, -- 蝦皮「商品名稱」
|
title TEXT NOT NULL, -- 蝦皮「商品名稱」
|
||||||
shopee_status TEXT, -- 蝦皮「商品當前狀態」
|
shopee_status TEXT, -- 蝦皮「商品當前狀態」
|
||||||
main_sku_code TEXT, -- 蝦皮「主商品貨號」
|
main_sku_code TEXT, -- 蝦皮「主商品貨號」
|
||||||
|
source VARCHAR(16) COLLATE utf8mb4_bin NOT NULL DEFAULT 'report', -- report / syb
|
||||||
|
|
||||||
-- 下面两个是我们自己维护的,报表里没有,导入时绝不能覆盖
|
-- 下面两个是我们自己维护的,报表里没有,导入时绝不能覆盖
|
||||||
pdd_goods_url TEXT, -- ★ 人工填写的 PDD 链接原文
|
pdd_goods_url TEXT, -- ★ 人工填写的 PDD 链接原文
|
||||||
@@ -149,11 +156,18 @@ CREATE TABLE shopee_products (
|
|||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE shopee_products ADD CONSTRAINT chk_shopee_products_source
|
||||||
|
CHECK (source IN ('report', 'syb'));
|
||||||
|
|
||||||
CREATE INDEX idx_shopee_products_pdd ON shopee_products(pdd_goods_id);
|
CREATE INDEX idx_shopee_products_pdd ON shopee_products(pdd_goods_id);
|
||||||
```
|
```
|
||||||
|
|
||||||
`[必须]` **采集结果和采集状态不在这张表里**,它们属于 PDD 商品,见 §4。
|
`[必须]` **采集结果和采集状态不在这张表里**,它们属于 PDD 商品,见 §4。
|
||||||
|
|
||||||
|
`source='syb'` 表示顺运宝先到、蝦皮报表尚未导入时创建的最小商品骨架。
|
||||||
|
后续 Excel 导入同一 `goods_id` 时必须补全商品信息并把来源提升为 `report`;
|
||||||
|
导入不得覆盖人工维护的 PDD 关联。
|
||||||
|
|
||||||
`pdd_goods_id` 表示"这个蝦皮商品**当前**对应哪个 PDD 商品"。
|
`pdd_goods_id` 表示"这个蝦皮商品**当前**对应哪个 PDD 商品"。
|
||||||
PDD 商品下架换代时改这里,是一个随时会变的关联,不是永久绑定。
|
PDD 商品下架换代时改这里,是一个随时会变的关联,不是永久绑定。
|
||||||
|
|
||||||
@@ -299,7 +313,7 @@ CREATE INDEX idx_pdd_products_status ON pdd_products(collect_status);
|
|||||||
|
|
||||||
**为什么用软删除**
|
**为什么用软删除**
|
||||||
|
|
||||||
`sku_mappings` 指向这张表。硬删会把人工攒了很久的匹配成果一起带走。
|
`spec_mappings` 指向这张表。硬删会把人工攒了很久的匹配成果一起带走。
|
||||||
软删除后界面不再显示,但记录和映射都还在。
|
软删除后界面不再显示,但记录和映射都还在。
|
||||||
|
|
||||||
`[必须]` 操作员重新填同一个链接时**要能复活**(清 `deleted_at`、状态置回
|
`[必须]` 操作员重新填同一个链接时**要能复活**(清 `deleted_at`、状态置回
|
||||||
@@ -434,9 +448,10 @@ CREATE TABLE syb_orders (
|
|||||||
syb_id TEXT PRIMARY KEY, -- 货运单**明细行** ID(顺运宝 details[].id)
|
syb_id TEXT PRIMARY KEY, -- 货运单**明细行** ID(顺运宝 details[].id)
|
||||||
order_no TEXT NOT NULL, -- 订单号(顺运宝外层 code,不是 orderCode)
|
order_no TEXT NOT NULL, -- 订单号(顺运宝外层 code,不是 orderCode)
|
||||||
title TEXT, -- 商品标题
|
title TEXT, -- 商品标题
|
||||||
product_spec TEXT, -- 规格原文,v5 新增,对应 shopee_skus.spec_raw
|
product_spec TEXT, -- 顺运宝规格原文
|
||||||
|
spec_key VARCHAR(191), -- 规格身份键;空规格保持 NULL
|
||||||
shopee_goods_id TEXT, -- 蝦皮商品 ID(顺运宝 productId,11 位)
|
shopee_goods_id TEXT, -- 蝦皮商品 ID(顺运宝 productId,11 位)
|
||||||
shopee_sku_id TEXT, -- 蝦皮规格 ID,人工/自动匹配的结果
|
shopee_sku_id TEXT, -- 历史兼容列;新主链路不读写
|
||||||
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
||||||
price_twd_cent INTEGER CHECK (price_twd_cent IS NULL OR price_twd_cent >= 0),
|
price_twd_cent INTEGER CHECK (price_twd_cent IS NULL OR price_twd_cent >= 0),
|
||||||
image_url TEXT, -- 存 URL,不存图片本身
|
image_url TEXT, -- 存 URL,不存图片本身
|
||||||
@@ -453,24 +468,12 @@ CREATE INDEX idx_syb_orders_list ON syb_orders(updated_at DESC, syb_id DESC);
|
|||||||
- `price_twd_cent` 是**台币分**,是蝦皮那边的售价,
|
- `price_twd_cent` 是**台币分**,是蝦皮那边的售价,
|
||||||
和采购任务的人民币价格上限**没有换算关系**,不要互相赋值。
|
和采购任务的人民币价格上限**没有换算关系**,不要互相赋值。
|
||||||
- `image_url` 存 URL。`[必须]` 不要把图片二进制存进数据库。
|
- `image_url` 存 URL。`[必须]` 不要把图片二进制存进数据库。
|
||||||
- `shopee_sku_id` **直接对应 `shopee_skus.sku_id`**,编号格式一致,不需要额外转换。
|
- `spec_key` 通过 `SpecKey(product_spec)` 计算:去除首尾空白,把连续空白折叠为一个半角空格,
|
||||||
|
但不做大小写、繁简、颜色或单位转换。空规格不生成键,并在采购流程中作为数据异常阻断。
|
||||||
`[必须]` 但**不要加外键约束**。理由:蝦皮报表不是全量目录(样本里平均每商品仅 1.17 个 SKU),
|
- **处理阶段是派生的,不存字段**:PDD 规格是否已匹配要按
|
||||||
货运单来了而本地查不到这个 SKU 是**常态**。加了外键,同步就会直接失败。
|
`(shopee_goods_id, spec_key, 当前 pdd_goods_id)` 联查 `spec_mappings`,并确认 `pdd_option_key`
|
||||||
|
仍存在于最新 `skus_json`。
|
||||||
正确做法是软关联:查不到时照常保存货运单,界面上标出"SKU 未收录",
|
- `shopee_sku_id` 只为兼容历史数据保留;顺运宝同步、弹窗、映射和建采购任务均不再读写它。
|
||||||
并引导操作员到蝦皮数据模块手动新增(见 [05 界面规范](05-ui-specification.md) §4.4)。
|
|
||||||
|
|
||||||
**"编号能对上"和"本地一定查得到"是两回事,别混。**
|
|
||||||
|
|
||||||
**处理阶段是派生的,不存字段**:`shopee_sku_id` 非空只表示蝦皮规格已确认。
|
|
||||||
PDD 规格是否已匹配要联查 `sku_mappings`,同时限定 `shopee_sku_id` 和当前
|
|
||||||
`pdd_goods_id`,并确认 `pdd_option_key` 仍存在于最新 `skus_json`;选项消失后按待匹配处理。
|
|
||||||
|
|
||||||
`[必须]` **`shopee_sku_id` 顺运宝同步绝不能覆盖**(工单 #46)。它是蝦皮规格识别
|
|
||||||
结果(人工确认或确定性唯一识别产生),顺运宝那边根本没有这个值(顺运宝只给商品级 `productId`,
|
|
||||||
不含蝦皮規格ID,见 §5.1 下方接口对照)。`repository.UpsertSybOrder` 的
|
|
||||||
`ON CONFLICT DO UPDATE SET` 里不出现这一列,新建行时才会写它(此时通常是空值)。
|
|
||||||
|
|
||||||
`[必须]` 一行对应顺运宝一张货运单的**一个商品明细**(`details[]` 的一项),
|
`[必须]` 一行对应顺运宝一张货运单的**一个商品明细**(`details[]` 的一项),
|
||||||
不是一张货运单——一张货运单可以有多个商品,各占一行,`syb_id` 用的是
|
不是一张货运单——一张货运单可以有多个商品,各占一行,`syb_id` 用的是
|
||||||
@@ -518,33 +521,35 @@ CREATE TABLE syb_sync_state (
|
|||||||
`[必须]` 本表不保存顺运宝 Cookie、token、密码、验证码、收件信息或原始响应。
|
`[必须]` 本表不保存顺运宝 Cookie、token、密码、验证码、收件信息或原始响应。
|
||||||
失败原因最多保留 500 个字符,并在页面输出时由模板转义。
|
失败原因最多保留 500 个字符,并在页面输出时由模板转义。
|
||||||
|
|
||||||
## 6. `sku_mappings` 规格映射
|
## 6. `spec_mappings` 顺运宝规格映射
|
||||||
|
|
||||||
"蝦皮的这个规格 = 拼多多的那个规格",**匹配一次,以后复用**。
|
"这个蝦皮商品的这条顺运宝规格 = 当前拼多多商品的那个规格",**匹配一次,以后复用**。
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE sku_mappings (
|
CREATE TABLE spec_mappings (
|
||||||
shopee_sku_id TEXT NOT NULL,
|
shopee_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||||
pdd_goods_id TEXT NOT NULL, -- ★ 这条映射属于哪个 PDD 商品
|
spec_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||||
pdd_option_key TEXT NOT NULL, -- 规范化的组合键,见 §6.2
|
pdd_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||||
pdd_options TEXT NOT NULL, -- 原始 options 对象,显示用
|
pdd_option_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, -- 见 §6.2
|
||||||
goods_id TEXT NOT NULL, -- 蝦皮商品 ID,方便按商品批量查
|
pdd_options LONGTEXT NOT NULL, -- 原始 options JSON,显示用
|
||||||
mapped_at TEXT NOT NULL,
|
spec_raw TEXT NOT NULL,
|
||||||
mapped_by TEXT,
|
mapped_at VARCHAR(35) NOT NULL,
|
||||||
PRIMARY KEY (shopee_sku_id, pdd_goods_id),
|
mapped_by VARCHAR(191),
|
||||||
FOREIGN KEY (shopee_sku_id) REFERENCES shopee_skus(sku_id) ON DELETE CASCADE
|
PRIMARY KEY (shopee_goods_id, spec_key, pdd_goods_id),
|
||||||
|
KEY idx_spec_mappings_goods (shopee_goods_id),
|
||||||
|
KEY idx_spec_mappings_pdd (pdd_goods_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_sku_mappings_goods ON sku_mappings(goods_id);
|
|
||||||
CREATE INDEX idx_sku_mappings_pdd ON sku_mappings(pdd_goods_id);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6.1 为什么主键要带上 `pdd_goods_id`
|
`[必须]` 不加外键。顺运宝明细可能比蝦皮报表更早到达,同步要先创建 skeleton 商品;
|
||||||
|
PDD 商品还可软删除,映射作为审计和可恢复数据必须保留。
|
||||||
|
|
||||||
|
### 6.1 为什么主键要带上商品和 `pdd_goods_id`
|
||||||
|
|
||||||
PDD 商品下架换代很频繁——A 买不到了就得换 B。
|
PDD 商品下架换代很频繁——A 买不到了就得换 B。
|
||||||
|
|
||||||
假设蝦皮商品 X 原来对应 PDD 商品 A,操作员匹配好了"黑色/M → 黑色/M码";
|
假设蝦皮商品 X 原来对应 PDD 商品 A,操作员匹配好了"黑色/M → 黑色/M码";
|
||||||
后来 A 下架,换成了 B。如果映射只按 `shopee_sku_id` 存,那条旧映射还在,
|
后来 A 下架,换成了 B。如果映射不带 `pdd_goods_id`,那条旧映射还在,
|
||||||
但它描述的是 **A 的规格**:
|
但它描述的是 **A 的规格**:
|
||||||
|
|
||||||
| | 后果 |
|
| | 后果 |
|
||||||
@@ -555,8 +560,9 @@ PDD 商品下架换代很频繁——A 买不到了就得换 B。
|
|||||||
把 `pdd_goods_id` 放进主键后,`[必须]` 查映射**永远带上"当前对应的 PDD 商品"**:
|
把 `pdd_goods_id` 放进主键后,`[必须]` 查映射**永远带上"当前对应的 PDD 商品"**:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
SELECT ... FROM sku_mappings
|
SELECT ... FROM spec_mappings
|
||||||
WHERE shopee_sku_id = ?
|
WHERE shopee_goods_id = ?
|
||||||
|
AND spec_key = ?
|
||||||
AND pdd_goods_id = (蝦皮商品当前的 pdd_goods_id)
|
AND pdd_goods_id = (蝦皮商品当前的 pdd_goods_id)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -701,12 +707,12 @@ CREATE TABLE clients (
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
shopee_products ──1:N──→ shopee_skus
|
shopee_products ──1:N──→ shopee_skus
|
||||||
│ │
|
│
|
||||||
│ pdd_goods_id │ shopee_sku_id
|
│ pdd_goods_id(当前 PDD 商品,可换)
|
||||||
│ (当前对应哪个 ↓
|
↓
|
||||||
│ PDD 商品,可换) sku_mappings ──pdd_goods_id──┐
|
pdd_products ←─pdd_goods_id─ spec_mappings
|
||||||
↓ │
|
↑
|
||||||
pdd_products ←────────────────────────────────────────┘
|
syb_orders ─(shopee_goods_id, spec_key)─┘
|
||||||
(skus_json 里是所有规格和价格)
|
(skus_json 里是所有规格和价格)
|
||||||
|
|
||||||
syb_orders ──创建──→ tasks ──分配──→ clients
|
syb_orders ──创建──→ tasks ──分配──→ clients
|
||||||
@@ -717,7 +723,7 @@ users(采购员)──1:N 当前归属──────────┘
|
|||||||
两条关联都可以变,这是有意的:
|
两条关联都可以变,这是有意的:
|
||||||
|
|
||||||
- `shopee_products.pdd_goods_id`:PDD 商品下架换代时改
|
- `shopee_products.pdd_goods_id`:PDD 商品下架换代时改
|
||||||
- `sku_mappings` 按 `(蝦皮SKU, PDD商品)` 存:换了商品自然查不到旧映射
|
- `spec_mappings` 按 `(蝦皮商品, 顺运宝规格键, PDD商品)` 存:换了商品自然查不到旧映射
|
||||||
|
|
||||||
## 11. 与 Client 数据模型的关系
|
## 11. 与 Client 数据模型的关系
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,7 @@
|
|||||||
| ☐ | 勾选 |
|
| ☐ | 勾选 |
|
||||||
| 商品 ID | |
|
| 商品 ID | |
|
||||||
| 商品名称 | 截断 + 悬停完整 |
|
| 商品名称 | 截断 + 悬停完整 |
|
||||||
|
| 数据来源 | 蝦皮报表 / 顺运宝补建;不只靠颜色区分 |
|
||||||
| 颜色 / 尺码 / 建议 | 解析失败的显示为 `—` 并**整行标黄**,提示需人工补 |
|
| 颜色 / 尺码 / 建议 | 解析失败的显示为 `—` 并**整行标黄**,提示需人工补 |
|
||||||
| PDD 链接 | 空的显示"**未填写**"并标红,这是最需要操作员注意的状态 |
|
| PDD 链接 | 空的显示"**未填写**"并标红,这是最需要操作员注意的状态 |
|
||||||
| 采集状态 | 未填链接 / 未采集 / 采集中 / 已采集 / 采集失败。**数据来自两张表**,判断方式见 [01 需求](01-requirements.md) §6.1 |
|
| 采集状态 | 未填链接 / 未采集 / 采集中 / 已采集 / 采集失败。**数据来自两张表**,判断方式见 [01 需求](01-requirements.md) §6.1 |
|
||||||
@@ -197,6 +198,9 @@
|
|||||||
|
|
||||||
### 4.4 手动新增
|
### 4.4 手动新增
|
||||||
|
|
||||||
|
> 本节是旧 SKU 确认流程的历史要求,已被工单 #88 的顺运宝 `spec_key`
|
||||||
|
> 主链路取代;保留仅为说明蝦皮报表数据可继续独立维护,顺运宝处理不再以此为前置条件。
|
||||||
|
|
||||||
`[必须]` 必须提供"新增 SKU"入口。
|
`[必须]` 必须提供"新增 SKU"入口。
|
||||||
|
|
||||||
原因:蝦皮报表只包含**有销售成绩的** SKU(样本里平均每个商品仅 1.17 个),
|
原因:蝦皮报表只包含**有销售成绩的** SKU(样本里平均每个商品仅 1.17 个),
|
||||||
@@ -468,27 +472,25 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
|
|||||||
|
|
||||||
### 6.2 表格列
|
### 6.2 表格列
|
||||||
|
|
||||||
☐ / 货运单明细ID / 订单号 / 商品标题 / 规格 / 蝦皮商品ID / 规格SKU / 数量 /
|
☐ / 货运单明细ID / 订单号 / 商品标题 / 规格 / 蝦皮商品ID / 数量 /
|
||||||
价格(台币)/ 图片 / **处理阶段** / **下一步** / 更新时间
|
价格(台币)/ 图片 / **处理阶段** / **下一步** / 更新时间
|
||||||
|
|
||||||
- 一行对应顺运宝一张货运单的**一个商品明细**,不是一张货运单——一张货运单
|
- 一行对应顺运宝一张货运单的**一个商品明细**,不是一张货运单——一张货运单
|
||||||
可以有多个商品,各占一行。
|
可以有多个商品,各占一行。
|
||||||
- 图片显示小缩略图。`[必须]` 存 URL,不要把图片塞进数据库。
|
- 图片显示小缩略图。`[必须]` 存 URL,不要把图片塞进数据库。
|
||||||
- 处理阶段是按当前关联事实**算出来的**,不是存一份容易过期的状态字段。阶段至少区分:
|
- 处理阶段是按当前关联事实**算出来的**,不是存一份容易过期的状态字段。阶段至少区分:
|
||||||
未找到蝦皮商品、待确认蝦皮规格、未关联 PDD、PDD 待采集、采集中、采集失败、
|
采购数据异常(顺运宝未提供规格)、未关联 PDD、PDD 待采集、采集中、
|
||||||
规格待匹配、可采购、采购数据异常和已创建任务。`shopee_sku_id` 非空只说明蝦皮规格已确认。
|
采集失败、规格待匹配、可采购和已创建任务。
|
||||||
- 每行显示一个有文字的“下一步”按钮;双击行和按钮打开同一个服务端渲染处理弹窗。
|
- 每行显示一个有文字的“下一步”按钮;双击行和按钮打开同一个服务端渲染处理弹窗。
|
||||||
- 规格原文完全一致,或解析后的颜色尺码只有一个唯一候选时,系统可以自动确认
|
- 规格原文为空时不显示匹配表单,明确提示“顺运宝未提供规格”,并禁止建立映射或创建采购任务。
|
||||||
蝦皮 SKU;零候选或多候选必须由采购员选择,不能模糊猜测。
|
|
||||||
- 完整货运单 JSON 不作为列显示,落在 `syb_data` 里,供后续排查用。
|
- 完整货运单 JSON 不作为列显示,落在 `syb_data` 里,供后续排查用。
|
||||||
|
|
||||||
### 6.3 采购处理与规格匹配弹窗(双击行或点击下一步打开)
|
### 6.3 采购处理与规格匹配弹窗(双击行或点击下一步打开)
|
||||||
|
|
||||||
弹窗按当前处理阶段渐进展示。第一步先确认蝦皮 SKU;顺运宝同步本身仍不提供
|
弹窗按当前处理阶段渐进展示。它直接以顺运宝规格原文及其 `spec_key`
|
||||||
`shopee_sku_id`,但同步写入明细后可以根据本地蝦皮数据做确定性唯一匹配。
|
连接当前 PDD 商品选项,不再经过蝦皮 SKU 确认步骤。PDD 关联、采集和规格映射使用同一个弹窗完成。
|
||||||
后续 PDD 关联、采集和规格映射使用同一个弹窗继续完成。
|
|
||||||
|
|
||||||
确认蝦皮 SKU 后,弹窗先展示当前 PDD 商品 ID、完整链接和采集状态。未关联时
|
弹窗先展示当前 PDD 商品 ID、完整链接和采集状态。未关联时
|
||||||
可直接填写完整链接;换成其他商品需要明确确认。待采集或采集失败时显示
|
可直接填写完整链接;换成其他商品需要明确确认。待采集或采集失败时显示
|
||||||
“创建采集任务”或“重新创建采集任务”,采集中和已采集状态只显示说明。
|
“创建采集任务”或“重新创建采集任务”,采集中和已采集状态只显示说明。
|
||||||
|
|
||||||
@@ -512,11 +514,11 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
|
|||||||
|
|
||||||
- 右侧下拉的选项来自该商品 `skus_json` 里的 `dimensions`,
|
- 右侧下拉的选项来自该商品 `skus_json` 里的 `dimensions`,
|
||||||
**不要写死"颜色/尺码"两个维度**——PDD 商品可能有第三个维度。
|
**不要写死"颜色/尺码"两个维度**——PDD 商品可能有第三个维度。
|
||||||
- **打开时先查 `sku_mappings`**,有记录就自动带出并提示"已自动带出"。
|
- **打开时先按 `(蝦皮商品 ID, spec_key, 当前 PDD 商品 ID)` 查 `spec_mappings`**,
|
||||||
这是省人工的关键:同一个蝦皮 SKU 只需人工匹配一次。
|
有记录就自动带出并提示"已自动带出"。
|
||||||
- 查询必须同时带当前 `pdd_goods_id`,并核对选项仍存在且可购买;换品或选项消失时回到待匹配。
|
- 查询必须同时带当前 `pdd_goods_id`,并核对选项仍存在且可购买;换品或选项消失时回到待匹配。
|
||||||
- 该商品**还没采集**(`skus_json` 为空)时,不要显示空下拉,直接展示关联和创建采集任务入口。
|
- 该商品**还没采集**(`skus_json` 为空)时,不要显示空下拉,直接展示关联和创建采集任务入口。
|
||||||
- 保存写的是 `sku_mappings`(可复用),**不是这一张订单的临时数据**。
|
- 保存写的是 `spec_mappings`(可复用),**不是这一张订单的临时数据**。
|
||||||
|
|
||||||
### 6.4 创建采购任务
|
### 6.4 创建采购任务
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user