feat: 蝦皮 Excel 报表导入 (#38)
蝦皮数据模块此前整个是骨架,四个入口全返回 501,excelize 连依赖都没装。 本工单做导入和列表,Save/Delete/Collect 保持 501。 真实样本实测:5195 个商品 / 6092 个 SKU,23 行解析失败。 upsert 白名单式,pdd_goods_url / pdd_goods_id 既不在 INSERT 列清单里 也不在 DO UPDATE SET 里。报表没有这两列,写进去就是写空值——操作员 攒几周的 PDD 链接会被一次导入洗光,而且不报错,等到建采购任务才发现。 有独立测试守着:往 DO UPDATE SET 里加回这一行,测试立刻变红。 按 sheet 名字取「最佳表現商品」,不用第 0 个。工作簿有 5 个 sheet, 另外 4 个是广告报表,连 商品規格ID 列都没有;蝦皮调顺序时按下标 会静默导入一张完全不相干的表。 按列名找索引。40 列里 32 列是统计指标,蝦皮加一列所有列号就错位, 而且不报错——会把「點擊率」当成「商品規格」存进去。 规格解析三种格式:含【】53.4%、逗号+空格 26.4%、只有逗号 20.2%, 只认【】会漏掉 46.6%。括号不配对(21 行)和右半整个被【】包住(2 行) 一律判整体失败,不逐段硬凑——错法不统一,针对每种错法写规则等于在猜, 而且没有人工核对过的正确答案可验证。失败时 color/size/advice 必须全空, 原文存进 spec_raw 交人工补。 不写 DELETE + INSERT 的全量替换,将来手动新增的 SKU 会被删掉。 excelize v2.9.1 的 go 指令正好是 1.23.0,与项目固定版本一致。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ go 1.23.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
|
github.com/xuri/excelize/v2 v2.9.1
|
||||||
modernc.org/sqlite v1.38.0
|
modernc.org/sqlite v1.38.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,8 +41,13 @@ require (
|
|||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||||
|
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||||
|
github.com/tiendc/go-deepcopy v1.6.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
|
github.com/xuri/nfp v0.0.1 // indirect
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/crypto v0.40.0 // indirect
|
golang.org/x/crypto v0.40.0 // indirect
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQB
|
|||||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
||||||
|
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
||||||
|
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
||||||
|
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -67,10 +72,18 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
|||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tiendc/go-deepcopy v1.6.0 h1:0UtfV/imoCwlLxVsyfUd4hNHnB3drXsfle+wzSCA5Wo=
|
||||||
|
github.com/tiendc/go-deepcopy v1.6.0/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||||
|
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
|
github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw=
|
||||||
|
github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s=
|
||||||
|
github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q=
|
||||||
|
github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||||
@@ -79,6 +92,8 @@ golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
|||||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||||
|
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||||
|
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||||
|
|||||||
+109
-24
@@ -1,9 +1,16 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"cmautobuy/admin/config"
|
||||||
|
"cmautobuy/admin/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ShopeeList 渲染蝦皮数据列表页。
|
// ShopeeList 渲染蝦皮数据列表页。
|
||||||
@@ -11,18 +18,7 @@ import (
|
|||||||
// 表格按 SKU 展开显示,数据来自 shopee_products 和 shopee_skus 联查。
|
// 表格按 SKU 展开显示,数据来自 shopee_products 和 shopee_skus 联查。
|
||||||
// 列定义见 docs/admin/05-ui-specification.md §4.2。
|
// 列定义见 docs/admin/05-ui-specification.md §4.2。
|
||||||
func (h *Handler) ShopeeList(c *gin.Context) {
|
func (h *Handler) ShopeeList(c *gin.Context) {
|
||||||
keyword := c.Query("goods_id")
|
h.renderShopeeList(c, c.Query("goods_id"), c.Query("msg"), nil)
|
||||||
|
|
||||||
// TODO(骨架): 调 service 查列表
|
|
||||||
// rows, err := service.ListShopeeSKUs(h.db, keyword, page, size)
|
|
||||||
// 注意:搜索要走数据库查询,不要一次查全量再在内存里过滤。
|
|
||||||
var rows []gin.H
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{
|
|
||||||
"Keyword": keyword,
|
|
||||||
"Rows": rows,
|
|
||||||
"Status": "尚未实现:导入后这里显示商品和规格",
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShopeeImport 处理 Excel 上传导入。
|
// ShopeeImport 处理 Excel 上传导入。
|
||||||
@@ -34,25 +30,108 @@ func (h *Handler) ShopeeList(c *gin.Context) {
|
|||||||
// - 规格原文两种格式各占一半,解析失败留空并把 parse_ok 置 0,不要猜;
|
// - 规格原文两种格式各占一半,解析失败留空并把 parse_ok 置 0,不要猜;
|
||||||
// - **只能 upsert,绝不允许先清空再导入**,
|
// - **只能 upsert,绝不允许先清空再导入**,
|
||||||
// 否则人工填的 PDD 链接会被洗掉;
|
// 否则人工填的 PDD 链接会被洗掉;
|
||||||
// - upsert 的 DO UPDATE SET 里不得出现 pdd_goods_url / pdd_data / collect_status。
|
// - upsert 的 DO UPDATE SET 里不得出现 pdd_goods_url / pdd_goods_id。
|
||||||
|
//
|
||||||
|
// 导入结果(含全部失败行)直接渲染在返回页面里,**不走 303 跳转**——
|
||||||
|
// 失败行可能有几十条,塞进跳转用的 URL 查询参数既丑又有长度限制,
|
||||||
|
// 没法满足"失败行要能全部看到"的要求(见工单 #38)。
|
||||||
|
// 代价是这个页面刷新(F5)会弹出浏览器自带的"重新提交表单"确认,
|
||||||
|
// 这是标准行为,比丢失失败行信息更容易接受。
|
||||||
func (h *Handler) ShopeeImport(c *gin.Context) {
|
func (h *Handler) ShopeeImport(c *gin.Context) {
|
||||||
file, err := c.FormFile("file")
|
// 限制整个请求体大小:50MB 上限 + 给表单其它字段留一点余量。
|
||||||
|
// 放在 FormFile 之前,这样超限时请求体读到一半就会出错,
|
||||||
|
// 不会先把整个超大文件读进内存再拒绝。
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.MaxShopeeUploadBytes+1<<20)
|
||||||
|
|
||||||
|
fileHeader, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(c, http.StatusBadRequest, "没有收到文件,请重新选择 Excel 后再提交。")
|
fail(c, http.StatusBadRequest,
|
||||||
|
"没有收到文件(也可能是文件超过了 50MB 上限),请重新选择 Excel 后再提交。未写入数据库。")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(骨架): 校验大小和扩展名(只允许 .xlsx),
|
src, err := fileHeader.Open()
|
||||||
// 落盘到 data/uploads/ 时用自己生成的文件名,
|
if err != nil {
|
||||||
// 不要直接用 file.Filename 拼路径(路径穿越)。
|
fail(c, http.StatusBadRequest, "无法打开上传的文件,请重试。未写入数据库。")
|
||||||
_ = file
|
return
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
// TODO(骨架): 调 service.ImportShopeeExcel(path),
|
// 只读文件开头几个字节判断是不是真的 xlsx(zip),不用整份读进内存。
|
||||||
// 返回 {商品数, SKU数, 解析失败行号列表} 并显示出来。
|
head := make([]byte, 8)
|
||||||
// 失败行不要静默跳过。
|
n, _ := io.ReadFull(src, head)
|
||||||
|
if err := service.ValidateShopeeUpload(fileHeader.Filename, fileHeader.Size, head[:n]); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, err.Error()+"。未写入数据库。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "读取上传文件失败,请重试。未写入数据库。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
fail(c, http.StatusNotImplemented,
|
uploadDir, err := config.SubDir("uploads")
|
||||||
"导入功能尚未实现。已保留你选择的文件,未写入数据库。")
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "无法准备上传目录,请重试。未写入数据库。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// `[必须]` 自己生成文件名,不用 fileHeader.Filename 拼路径——
|
||||||
|
// 用户可控的文件名里塞一个 "../../etc/passwd" 就是路径穿越。
|
||||||
|
savePath := filepath.Join(uploadDir, service.NewShopeeUploadFilename())
|
||||||
|
|
||||||
|
dst, err := os.Create(savePath)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "保存上传文件失败,请重试。未写入数据库。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(dst, src); err != nil {
|
||||||
|
dst.Close()
|
||||||
|
fail(c, http.StatusInternalServerError, "保存上传文件失败,请重试。未写入数据库。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := dst.Close(); err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "保存上传文件失败,请重试。未写入数据库。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := service.ImportShopeeExcel(h.db, savePath)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusBadRequest,
|
||||||
|
"导入失败:"+err.Error()+"。这次导入整体没有生效(已回滚),可以修好文件后重新上传。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("导入完成:%d 个商品 / %d 个 SKU", result.ProductCount, result.SKUCount)
|
||||||
|
if len(result.Failures) > 0 {
|
||||||
|
msg += fmt.Sprintf(",%d 行解析失败(见下方列表)", len(result.Failures))
|
||||||
|
}
|
||||||
|
h.renderShopeeList(c, "", msg, result.Failures)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderShopeeList 是 ShopeeList 和 ShopeeImport 共用的渲染逻辑。
|
||||||
|
func (h *Handler) renderShopeeList(c *gin.Context, keyword, msg string, failures []service.ImportFailure) {
|
||||||
|
result, err := service.ListShopeeSKUs(h.db, keyword)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError,
|
||||||
|
"读取蝦皮数据失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := msg
|
||||||
|
if status == "" {
|
||||||
|
status = fmt.Sprintf("共 %d 个商品", result.Total)
|
||||||
|
if result.IsFiltered {
|
||||||
|
status = fmt.Sprintf("筛选出 %d 条 / %s", len(result.Rows), status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{
|
||||||
|
"Keyword": keyword,
|
||||||
|
"Rows": result.Rows,
|
||||||
|
"Status": status,
|
||||||
|
"HasAnyProducts": result.Total > 0,
|
||||||
|
"IsFiltered": result.IsFiltered,
|
||||||
|
"Failures": failures,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShopeeSave 保存编辑弹窗里的内容(PDD 链接、颜色、尺码、建议)。
|
// ShopeeSave 保存编辑弹窗里的内容(PDD 链接、颜色、尺码、建议)。
|
||||||
@@ -60,6 +139,8 @@ func (h *Handler) ShopeeImport(c *gin.Context) {
|
|||||||
// 这是**从蝦皮商品出发**录入 PDD 链接的入口,
|
// 这是**从蝦皮商品出发**录入 PDD 链接的入口,
|
||||||
// 见 docs/admin/05-ui-specification.md §4.3。PDD 商品页(§5)也能录入,
|
// 见 docs/admin/05-ui-specification.md §4.3。PDD 商品页(§5)也能录入,
|
||||||
// 两处并存;本页这个目前还是骨架,合并与否由「蝦皮↔PDD 关联入口」工单决定。
|
// 两处并存;本页这个目前还是骨架,合并与否由「蝦皮↔PDD 关联入口」工单决定。
|
||||||
|
//
|
||||||
|
// `[不做]` 本工单(#38)明确不实现这个接口,保持 501。
|
||||||
func (h *Handler) ShopeeSave(c *gin.Context) {
|
func (h *Handler) ShopeeSave(c *gin.Context) {
|
||||||
// TODO(骨架): 保存 PDD 链接时调 repository.EnsurePddProduct,
|
// TODO(骨架): 保存 PDD 链接时调 repository.EnsurePddProduct,
|
||||||
// 由它去建 / 复用 / 复活 pdd_products 那一行。
|
// 由它去建 / 复用 / 复活 pdd_products 那一行。
|
||||||
@@ -72,6 +153,8 @@ func (h *Handler) ShopeeSave(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ShopeeDelete 批量删除勾选的行。
|
// ShopeeDelete 批量删除勾选的行。
|
||||||
|
//
|
||||||
|
// `[不做]` 本工单(#38)明确不实现这个接口,保持 501。
|
||||||
func (h *Handler) ShopeeDelete(c *gin.Context) {
|
func (h *Handler) ShopeeDelete(c *gin.Context) {
|
||||||
// TODO(骨架): 取 ids,二次确认已在前端做过,这里直接删
|
// TODO(骨架): 取 ids,二次确认已在前端做过,这里直接删
|
||||||
fail(c, http.StatusNotImplemented, "删除功能尚未实现。")
|
fail(c, http.StatusNotImplemented, "删除功能尚未实现。")
|
||||||
@@ -86,6 +169,8 @@ func (h *Handler) ShopeeDelete(c *gin.Context) {
|
|||||||
// - PDD 链接为空不允许发起;
|
// - PDD 链接为空不允许发起;
|
||||||
// - collect_status 已经是 collecting 的跳过并说明跳过了几个;
|
// - collect_status 已经是 collecting 的跳过并说明跳过了几个;
|
||||||
// - 批量采集要**按商品去重**后再建任务。
|
// - 批量采集要**按商品去重**后再建任务。
|
||||||
|
//
|
||||||
|
// `[不做]` 本工单(#38)明确不实现这个接口,保持 501。
|
||||||
func (h *Handler) ShopeeCollect(c *gin.Context) {
|
func (h *Handler) ShopeeCollect(c *gin.Context) {
|
||||||
// TODO(骨架): 调 service.CreateCollectTasks(goodsIDs, clientID)
|
// TODO(骨架): 调 service.CreateCollectTasks(goodsIDs, clientID)
|
||||||
fail(c, http.StatusNotImplemented, "采集功能尚未实现。")
|
fail(c, http.StatusNotImplemented, "采集功能尚未实现。")
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cmautobuy/admin/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpsertShopeeProduct 写入或更新一条蝦皮商品汇总行。
|
||||||
|
//
|
||||||
|
// `[必须]` DO UPDATE SET 里绝不允许出现 pdd_goods_url / pdd_goods_id。
|
||||||
|
// 蝦皮报表里没有这两列,写进去就是写空值——操作员可能攒了几周的
|
||||||
|
// PDD 链接,导入一次就被静默洗掉,而且不报错,等到建采购任务时
|
||||||
|
// 才会发现,那时已经找不回来了。见工单 #38、admin/AGENTS.md
|
||||||
|
// 「Excel 导入只做 upsert,绝不允许先清空再导入」。
|
||||||
|
func UpsertShopeeProduct(q Execer, goodsID, title, shopeeStatus, mainSKUCode string) error {
|
||||||
|
if goodsID == "" {
|
||||||
|
return fmt.Errorf("goods_id 不能为空")
|
||||||
|
}
|
||||||
|
now := model.NowISO()
|
||||||
|
_, err := q.Exec(`
|
||||||
|
INSERT INTO shopee_products (goods_id, title, shopee_status, main_sku_code, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(goods_id) DO UPDATE SET
|
||||||
|
title = excluded.title,
|
||||||
|
shopee_status = excluded.shopee_status,
|
||||||
|
main_sku_code = excluded.main_sku_code,
|
||||||
|
updated_at = excluded.updated_at`,
|
||||||
|
// 注意:pdd_goods_url / pdd_goods_id 两列不在 INSERT 的列清单里,
|
||||||
|
// 也不在 DO UPDATE SET 里——新建时它们是 NULL(未填链接),
|
||||||
|
// 已存在时它们完全不受这条语句影响。
|
||||||
|
goodsID, title, shopeeStatus, mainSKUCode, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("写入蝦皮商品 %s 失败: %w", goodsID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertShopeeSKU 写入或更新一条蝦皮 SKU 行。
|
||||||
|
//
|
||||||
|
// `[必须]` is_manual 不在 DO UPDATE SET 里,已存在的行导入时保持原值不变。
|
||||||
|
// 人工新增的 SKU(is_manual=1)就是靠这个不被导入覆盖成 0——
|
||||||
|
// 本工单不做手动新增功能,但这个口子现在就要留好。
|
||||||
|
func UpsertShopeeSKU(q Execer, skuID, goodsID, specRaw, color, size, advice string, parseOK bool, skuCode string) error {
|
||||||
|
if skuID == "" {
|
||||||
|
return fmt.Errorf("sku_id 不能为空")
|
||||||
|
}
|
||||||
|
if goodsID == "" {
|
||||||
|
return fmt.Errorf("goods_id 不能为空")
|
||||||
|
}
|
||||||
|
parseOKInt := 0
|
||||||
|
if parseOK {
|
||||||
|
parseOKInt = 1
|
||||||
|
}
|
||||||
|
now := model.NowISO()
|
||||||
|
_, err := q.Exec(`
|
||||||
|
INSERT INTO shopee_skus
|
||||||
|
(sku_id, goods_id, spec_raw, color, size, advice, parse_ok, sku_code,
|
||||||
|
is_manual, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||||
|
ON CONFLICT(sku_id) DO UPDATE SET
|
||||||
|
goods_id = excluded.goods_id,
|
||||||
|
spec_raw = excluded.spec_raw,
|
||||||
|
color = excluded.color,
|
||||||
|
size = excluded.size,
|
||||||
|
advice = excluded.advice,
|
||||||
|
parse_ok = excluded.parse_ok,
|
||||||
|
sku_code = excluded.sku_code,
|
||||||
|
updated_at = excluded.updated_at`,
|
||||||
|
// is_manual 不在上面的 SET 列表里,SQLite 对没提到的列保持原值不变。
|
||||||
|
skuID, goodsID, specRaw, color, size, advice, parseOKInt, skuCode, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("写入蝦皮 SKU %s 失败: %w", skuID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShopeeSKURow 是列表页要的一行:SKU 本身,联查出商品标题和采集状态。
|
||||||
|
//
|
||||||
|
// 采集状态挂在 pdd_products 上,不在蝦皮这边,见
|
||||||
|
// docs/admin/01-requirements.md §6.1。CollectStatus / CollectMsg 为
|
||||||
|
// NULL 表示这个蝦皮商品还没填 PDD 链接(LEFT JOIN 没查到对应行)。
|
||||||
|
type ShopeeSKURow struct {
|
||||||
|
model.ShopeeSKU
|
||||||
|
ProductTitle string
|
||||||
|
PddGoodsURL string
|
||||||
|
PddGoodsID string
|
||||||
|
CollectStatus sql.NullString
|
||||||
|
CollectMsg sql.NullString
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListShopeeSKUs 按蝦皮商品 ID 关键字查 SKU 列表(联查商品标题和采集状态)。
|
||||||
|
// keyword 为空表示不筛选。
|
||||||
|
func ListShopeeSKUs(q Execer, keyword string) ([]ShopeeSKURow, error) {
|
||||||
|
sqlText := `
|
||||||
|
SELECT sk.sku_id, sk.goods_id, sk.spec_raw, sk.color, sk.size, sk.advice,
|
||||||
|
sk.parse_ok, sk.sku_code, sk.is_manual, sk.created_at, sk.updated_at,
|
||||||
|
sp.title, sp.pdd_goods_url, sp.pdd_goods_id,
|
||||||
|
pp.collect_status, pp.collect_msg
|
||||||
|
FROM shopee_skus sk
|
||||||
|
JOIN shopee_products sp ON sp.goods_id = sk.goods_id
|
||||||
|
LEFT JOIN pdd_products pp
|
||||||
|
ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL`
|
||||||
|
var args []any
|
||||||
|
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
||||||
|
sqlText += ` WHERE sk.goods_id LIKE ? ESCAPE '\'`
|
||||||
|
args = append(args, "%"+escapeLike(keyword)+"%")
|
||||||
|
}
|
||||||
|
sqlText += ` ORDER BY sk.goods_id, sk.sku_id`
|
||||||
|
|
||||||
|
rows, err := q.Query(sqlText, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询蝦皮 SKU 列表失败: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var list []ShopeeSKURow
|
||||||
|
for rows.Next() {
|
||||||
|
var r ShopeeSKURow
|
||||||
|
var color, size, advice, skuCode sql.NullString
|
||||||
|
var parseOK, isManual int
|
||||||
|
var pddGoodsURL, pddGoodsID sql.NullString
|
||||||
|
if err := rows.Scan(
|
||||||
|
&r.SKUID, &r.GoodsID, &r.SpecRaw, &color, &size, &advice,
|
||||||
|
&parseOK, &skuCode, &isManual, &r.CreatedAt, &r.UpdatedAt,
|
||||||
|
&r.ProductTitle, &pddGoodsURL, &pddGoodsID,
|
||||||
|
&r.CollectStatus, &r.CollectMsg,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("读取蝦皮 SKU 列表失败: %w", err)
|
||||||
|
}
|
||||||
|
r.Color = color.String
|
||||||
|
r.Size = size.String
|
||||||
|
r.Advice = advice.String
|
||||||
|
r.SKUCode = skuCode.String
|
||||||
|
r.ParseOK = parseOK != 0
|
||||||
|
r.IsManual = isManual != 0
|
||||||
|
r.PddGoodsURL = pddGoodsURL.String
|
||||||
|
r.PddGoodsID = pddGoodsID.String
|
||||||
|
list = append(list, r)
|
||||||
|
}
|
||||||
|
return list, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountShopeeProducts 统计蝦皮商品总数,供列表页判断"是否已导入过任何数据"。
|
||||||
|
func CountShopeeProducts(q Execer) (int, error) {
|
||||||
|
var n int
|
||||||
|
if err := q.QueryRow(`SELECT COUNT(*) FROM shopee_products`).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("统计蝦皮商品数量失败: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
@@ -21,45 +21,9 @@ import (
|
|||||||
var ErrNotImplemented = errors.New("功能尚未实现")
|
var ErrNotImplemented = errors.New("功能尚未实现")
|
||||||
|
|
||||||
// ---------- 蝦皮 Excel 导入 ----------
|
// ---------- 蝦皮 Excel 导入 ----------
|
||||||
|
|
||||||
// ImportResult 是一次导入的统计结果,要显示给操作员看。
|
|
||||||
type ImportResult struct {
|
|
||||||
ProductCount int // 写入的商品数,样本应为 5195
|
|
||||||
SKUCount int // 写入的 SKU 数,样本应为 6092
|
|
||||||
FailedRows []int // 解析失败的行号,**不要静默跳过**
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImportShopeeExcel 解析蝦皮报表并 upsert 进库。
|
|
||||||
//
|
//
|
||||||
// 实现要点见 docs/admin/03-data-model.md §3.3,五步缺一不可:
|
// ImportResult / ImportShopeeExcel / ParseSpec 的实现见 shopee_import.go,
|
||||||
// 1. 分行:商品規格ID 为 "-" 的是商品汇总行,其余是 SKU 行;
|
// 那个文件还负责上传文件的安全校验(大小、扩展名、内容魔数)。
|
||||||
// 2. 按列名找索引,不要写死列号(报表 40 列,蝦皮改格式列号就变);
|
|
||||||
// 3. 解析规格原文:先按第一个逗号切开,右边再提取建议。
|
|
||||||
// 实测两种格式各占 53.4% / 46.6%,只认【】会漏掉一半。
|
|
||||||
// **任何一步失败都不要猜**,parse_ok 置 0,颜色尺码留空,原文照存;
|
|
||||||
// 4. upsert,**绝不清空**。DO UPDATE SET 里不得出现
|
|
||||||
// pdd_goods_url / pdd_data / collect_status——报表里没这些列,
|
|
||||||
// 写进去会把人工填的洗成空;
|
|
||||||
// 5. 返回统计。
|
|
||||||
func ImportShopeeExcel(db *sql.DB, path string) (*ImportResult, error) {
|
|
||||||
// TODO(骨架): 用 github.com/xuri/excelize/v2 流式读行
|
|
||||||
return nil, ErrNotImplemented
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseSpec 把蝦皮的规格原文拆成颜色、尺码、建议。
|
|
||||||
//
|
|
||||||
// 输入样例(两种格式各占一半):
|
|
||||||
//
|
|
||||||
// "黑色,M【建議40-50公斤】" -> 黑色 / M / 40-50公斤 / true
|
|
||||||
// "卡其色拼黑色,L 建議50-57.5kg" -> 卡其色拼黑色 / L / 50-57.5kg / true
|
|
||||||
// "莫名其妙的格式" -> "" / "" / "" / false
|
|
||||||
//
|
|
||||||
// 最后一个返回值是 ok;false 时前三个必须为空,**不要猜**。
|
|
||||||
// 调用方要把 parse_ok 存进库,界面上把这些行标出来让人工补。
|
|
||||||
func ParseSpec(raw string) (color, size, advice string, ok bool) {
|
|
||||||
// TODO(骨架): 按第一个逗号切开;右边先找【建議...】,再找空格后的 建議...
|
|
||||||
return "", "", "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 采集 ----------
|
// ---------- 采集 ----------
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
// 蝦皮 Excel 报表导入。
|
||||||
|
//
|
||||||
|
// 见 docs/admin/03-data-model.md §3.3(五步,缺一不可)和工单 #38。
|
||||||
|
// 改动前必读 admin/AGENTS.md 的分层约定:本层不碰 HTTP,也不拼 SQL。
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
|
||||||
|
"cmautobuy/admin/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------- 文件安全 ----------
|
||||||
|
|
||||||
|
// MaxShopeeUploadBytes 是上传文件的大小上限。
|
||||||
|
//
|
||||||
|
// 真实样本 1.9MB,50MB 已经是它的 26 倍,正常的报表增长不会碰到这个上限;
|
||||||
|
// 设上限是为了防止恶意或误传的超大文件把磁盘和内存占满。
|
||||||
|
const MaxShopeeUploadBytes = 50 * 1024 * 1024
|
||||||
|
|
||||||
|
// xlsxMagic 是 zip 本地文件头的魔数。xlsx 本质上是一个 zip 包,
|
||||||
|
// 光看扩展名判断不了内容——文件改个后缀就能绕过,所以要连内容一起查。
|
||||||
|
var xlsxMagic = []byte{0x50, 0x4B, 0x03, 0x04}
|
||||||
|
|
||||||
|
// ValidateShopeeUpload 校验上传的文件是否可以接受。
|
||||||
|
// head 至少要传前 4 个字节(调用方从流里读出来,不需要读整个文件)。
|
||||||
|
func ValidateShopeeUpload(filename string, size int64, head []byte) error {
|
||||||
|
if size <= 0 {
|
||||||
|
return fmt.Errorf("文件是空的")
|
||||||
|
}
|
||||||
|
if size > MaxShopeeUploadBytes {
|
||||||
|
return fmt.Errorf("文件超过 50MB 上限(当前约 %.1fMB)", float64(size)/1024/1024)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(filepath.Ext(filename), ".xlsx") {
|
||||||
|
return fmt.Errorf("只允许 .xlsx 文件,收到的是 %q", filepath.Ext(filename))
|
||||||
|
}
|
||||||
|
if !bytes.HasPrefix(head, xlsxMagic) {
|
||||||
|
return fmt.Errorf("文件内容不像一个 xlsx(zip 格式)文件,可能是改了扩展名的其他文件")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewShopeeUploadFilename 生成落盘用的文件名。
|
||||||
|
//
|
||||||
|
// `[必须]` 不能用用户上传时的原始文件名拼路径——那是外部输入,
|
||||||
|
// 塞一个 "../../etc/passwd" 之类的名字就是路径穿越。
|
||||||
|
// 自己生成的名字只含十六进制字符,天然安全。
|
||||||
|
func NewShopeeUploadFilename() string {
|
||||||
|
return "shopee-" + newID() + ".xlsx"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 导入 ----------
|
||||||
|
|
||||||
|
// ShopeeSheetName 是报表里真正装数据的那个 sheet。
|
||||||
|
//
|
||||||
|
// `[必须]` 按名字取,不许用第 0 个下标。工作簿另外还有 4 个 sheet
|
||||||
|
// (新上架商品 / 高潛力廣告商品 / 優化商品廣告 / 追蹤商品廣告成效),
|
||||||
|
// 全是广告报表,连「商品規格ID」列都没有。它现在恰好是第 0 个,
|
||||||
|
// 但蝦皮调整报表顺序后,按下标取会静默导入一张完全不相干的表。
|
||||||
|
const ShopeeSheetName = "最佳表現商品"
|
||||||
|
|
||||||
|
// shopeeRequiredColumns 是导入必须用到的 8 个业务列。
|
||||||
|
//
|
||||||
|
// `[必须]` 按列名找索引,不写死列号:报表现在是 40 列,
|
||||||
|
// 蝦皮加一列统计指标所有列号就全部错位,而且不报错——
|
||||||
|
// 会把「點擊率」当成「商品規格」存进去。
|
||||||
|
var shopeeRequiredColumns = []string{
|
||||||
|
"商品ID", "商品名稱", "商品當前狀態", "商品規格ID",
|
||||||
|
"商品規格", "規格當前狀態", "商品選項貨號", "主商品貨號",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportFailure 是一行解析失败的记录,行号是 Excel 里的真实行号
|
||||||
|
// (从 1 开始,含表头),方便操作员直接去 Excel 里核对。
|
||||||
|
//
|
||||||
|
// `[必须]` 不许静默跳过失败行——返回结构里必须带行号、原文和原因,
|
||||||
|
// 界面上要能全部看到,不能只显示"失败 N 行"。
|
||||||
|
type ImportFailure struct {
|
||||||
|
Row int
|
||||||
|
Raw string
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportResult 是一次导入的统计结果,要显示给操作员看。
|
||||||
|
type ImportResult struct {
|
||||||
|
ProductCount int
|
||||||
|
SKUCount int
|
||||||
|
Failures []ImportFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
// shopeeProductRow / shopeeSKURow 是解析阶段的中间结果。
|
||||||
|
//
|
||||||
|
// 为什么先解析进内存、再统一写库,而不是边读边写:
|
||||||
|
// SKU 行要靠 goods_id 外键指向 shopee_products,写入顺序必须是
|
||||||
|
// "全部商品先写完,再写 SKU"。工单没有保证 Excel 里商品汇总行
|
||||||
|
// 一定排在它的 SKU 行前面(样本里恰好是,但不能依赖这个巧合)。
|
||||||
|
// 两阶段比"假设文件里的行序"更可靠,11287 行的内存开销可以忽略。
|
||||||
|
//
|
||||||
|
// 这不违反"流式读,不要 GetRows()"的要求——那条针对的是 excelize
|
||||||
|
// 内部一次性把 40 列全部读出来的开销,这里用 Rows() 逐行读、
|
||||||
|
// 只把用得到的 8 个字段摘出来存进自己的小结构体。
|
||||||
|
type shopeeProductRow struct {
|
||||||
|
GoodsID string
|
||||||
|
Title string
|
||||||
|
ShopeeStatus string
|
||||||
|
MainSKUCode string
|
||||||
|
}
|
||||||
|
|
||||||
|
type shopeeSKURow struct {
|
||||||
|
SKUID string
|
||||||
|
GoodsID string
|
||||||
|
SpecRaw string
|
||||||
|
Color string
|
||||||
|
Size string
|
||||||
|
Advice string
|
||||||
|
ParseOK bool
|
||||||
|
SKUCode string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportShopeeExcel 解析蝦皮报表并 upsert 进库。
|
||||||
|
//
|
||||||
|
// 五步(docs/admin/03-data-model.md §3.3):
|
||||||
|
// 1. 按 sheet 名字取数据,分行(商品規格ID == "-" 是商品汇总行,其余是 SKU 行);
|
||||||
|
// 2. 按列名找索引;
|
||||||
|
// 3. 解析规格原文,解析失败就留空、不猜;
|
||||||
|
// 4. upsert,绝不清空、绝不碰人工字段;
|
||||||
|
// 5. 返回统计(含失败行号)。
|
||||||
|
func ImportShopeeExcel(db *sql.DB, path string) (*ImportResult, error) {
|
||||||
|
f, err := excelize.OpenFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("打开 Excel 文件失败: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if !hasSheet(f, ShopeeSheetName) {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"工作簿里没有「%s」这个 sheet,实际有的 sheet 是: %s",
|
||||||
|
ShopeeSheetName, strings.Join(f.GetSheetList(), "、"))
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := f.Rows(ShopeeSheetName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取 sheet「%s」失败: %w", ShopeeSheetName, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
if !rows.Next() {
|
||||||
|
return nil, fmt.Errorf("sheet「%s」是空的,连表头都没有", ShopeeSheetName)
|
||||||
|
}
|
||||||
|
header, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取表头失败: %w", err)
|
||||||
|
}
|
||||||
|
colIdx, err := shopeeColumnIndex(header)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
products []shopeeProductRow
|
||||||
|
skus []shopeeSKURow
|
||||||
|
failures []ImportFailure
|
||||||
|
)
|
||||||
|
seenGoods := map[string]bool{}
|
||||||
|
|
||||||
|
rowNum := 1 // 表头是第 1 行
|
||||||
|
for rows.Next() {
|
||||||
|
rowNum++
|
||||||
|
cells, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
failures = append(failures, ImportFailure{
|
||||||
|
Row: rowNum, Reason: "读取这一行失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
goodsID := strings.TrimSpace(cellAt(cells, colIdx["商品ID"]))
|
||||||
|
if goodsID == "" {
|
||||||
|
failures = append(failures, ImportFailure{Row: rowNum, Reason: "商品ID 为空,整行跳过"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
specID := strings.TrimSpace(cellAt(cells, colIdx["商品規格ID"]))
|
||||||
|
if specID == "" || specID == "-" {
|
||||||
|
// 商品汇总行:一个商品在报表里只出现一次,但防御性地去重,
|
||||||
|
// 避免万一撞见重复行时把同一个商品塞进 products 两次,
|
||||||
|
// 让 ProductCount 统计出错。
|
||||||
|
if !seenGoods[goodsID] {
|
||||||
|
seenGoods[goodsID] = true
|
||||||
|
products = append(products, shopeeProductRow{
|
||||||
|
GoodsID: goodsID,
|
||||||
|
Title: strings.TrimSpace(cellAt(cells, colIdx["商品名稱"])),
|
||||||
|
ShopeeStatus: strings.TrimSpace(cellAt(cells, colIdx["商品當前狀態"])),
|
||||||
|
MainSKUCode: strings.TrimSpace(cellAt(cells, colIdx["主商品貨號"])),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// SKU 行。解析失败不代表这一行不导入——spec_raw 仍然要存,
|
||||||
|
// 只是 color/size/advice 留空、parse_ok=0,操作员在界面上人工补,
|
||||||
|
// 见 ParseSpec 的注释。
|
||||||
|
specRaw := cellAt(cells, colIdx["商品規格"])
|
||||||
|
color, size, advice, ok := ParseSpec(specRaw)
|
||||||
|
if !ok {
|
||||||
|
failures = append(failures, ImportFailure{
|
||||||
|
Row: rowNum, Raw: specRaw,
|
||||||
|
Reason: "规格原文解析不了,已按原文保存,parse_ok=0,需要人工补颜色/尺码",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
skus = append(skus, shopeeSKURow{
|
||||||
|
SKUID: specID,
|
||||||
|
GoodsID: goodsID,
|
||||||
|
SpecRaw: specRaw,
|
||||||
|
Color: color,
|
||||||
|
Size: size,
|
||||||
|
Advice: advice,
|
||||||
|
ParseOK: ok,
|
||||||
|
SKUCode: strings.TrimSpace(cellAt(cells, colIdx["商品選項貨號"])),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := rows.Error(); err != nil {
|
||||||
|
return nil, fmt.Errorf("读取 sheet「%s」出错: %w", ShopeeSheetName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `[建议]` 整个导入放一个事务里,失败整体回滚——
|
||||||
|
// 导入一半的数据比没导入更难收拾。
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("开始导入事务失败: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() // 已提交的事务再 Rollback 是空操作,安全
|
||||||
|
|
||||||
|
for _, p := range products {
|
||||||
|
if err := repository.UpsertShopeeProduct(tx, p.GoodsID, p.Title, p.ShopeeStatus, p.MainSKUCode); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range skus {
|
||||||
|
if err := repository.UpsertShopeeSKU(
|
||||||
|
tx, s.SKUID, s.GoodsID, s.SpecRaw, s.Color, s.Size, s.Advice, s.ParseOK, s.SKUCode,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, fmt.Errorf("提交导入事务失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ImportResult{
|
||||||
|
ProductCount: len(products),
|
||||||
|
SKUCount: len(skus),
|
||||||
|
Failures: failures,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasSheet(f *excelize.File, name string) bool {
|
||||||
|
for _, s := range f.GetSheetList() {
|
||||||
|
if s == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// shopeeColumnIndex 按表头文字算出每个必需列的下标。
|
||||||
|
// 缺列时把缺的列名全部列出来,不要只报第一个——操作员一次能看全,
|
||||||
|
// 不用改一列、重传、再发现缺下一列。
|
||||||
|
func shopeeColumnIndex(header []string) (map[string]int, error) {
|
||||||
|
idx := map[string]int{}
|
||||||
|
for i, name := range header {
|
||||||
|
idx[strings.TrimSpace(name)] = i
|
||||||
|
}
|
||||||
|
var missing []string
|
||||||
|
for _, name := range shopeeRequiredColumns {
|
||||||
|
if _, ok := idx[name]; !ok {
|
||||||
|
missing = append(missing, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return nil, fmt.Errorf("表头缺少必需列: %s(实际表头: %s)",
|
||||||
|
strings.Join(missing, "、"), strings.Join(header, "、"))
|
||||||
|
}
|
||||||
|
return idx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cellAt 安全取某一列的值。
|
||||||
|
// excelize 的 Columns() 只返回到这一行最后一个非空单元格为止,
|
||||||
|
// 行尾有空单元格被省略是正常情况,不是数据问题,越界当空值处理。
|
||||||
|
func cellAt(cells []string, i int) string {
|
||||||
|
if i < 0 || i >= len(cells) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return cells[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 规格解析 ----------
|
||||||
|
|
||||||
|
// ParseSpec 把蝦皮的规格原文拆成颜色、尺码、建议。
|
||||||
|
//
|
||||||
|
// 输入样例(实测两种格式各占一半):
|
||||||
|
//
|
||||||
|
// "黑色,M【建議40-50公斤】" -> 黑色 / M / 40-50公斤 / true
|
||||||
|
// "卡其色拼黑色,L 建議50-57.5kg" -> 卡其色拼黑色 / L / 50-57.5kg / true
|
||||||
|
// "黑色,M" -> 黑色 / M / "" / true
|
||||||
|
// "莫名其妙的格式" -> "" / "" / "" / false
|
||||||
|
//
|
||||||
|
// 最后一个返回值是 ok;false 时前三个必须为空,**不要猜**——
|
||||||
|
// 猜出来的颜色尺码会一路传到采购任务,最后买错东西。
|
||||||
|
//
|
||||||
|
// # 21 行括号不配对的脏数据怎么处理
|
||||||
|
//
|
||||||
|
// 实测样本里有 21 行(约 0.3%)出现【】不配对,例如:
|
||||||
|
//
|
||||||
|
// "紅色,3XL建議80-90公斤】" 缺左括号
|
||||||
|
// "粉色,3XL【寬鬆版 82.5-92.5kg" 缺右括号,而且用的是"寬鬆版"不是"建議"
|
||||||
|
//
|
||||||
|
// 这里选择的行为是:**只要整条原文里【和】数量不相等,就整体判定
|
||||||
|
// ok=false**,不去猜哪一段该算尺码、哪一段该算建议。
|
||||||
|
//
|
||||||
|
// 理由:这 21 行的错法并不统一——有的缺左括号、有的缺右括号、
|
||||||
|
// 有的干脆用别的词代替"建議"。想"能提取多少算多少"就得针对每种
|
||||||
|
// 错法单独写规则,规则越写越多,而且没有办法验证这些规则对不对
|
||||||
|
// (没有人工核对过的正确答案)。相比之下,"括号不配对就是脏数据,
|
||||||
|
// 交给人工看"是一条简单、每次都一样、不会越猜越错的规则,
|
||||||
|
// 符合 admin/AGENTS.md「蝦皮规格原文永远保留,解析不出来就留空,
|
||||||
|
// 不要瞎猜」。原文仍然会存进 spec_raw,界面上标出来即可。
|
||||||
|
func ParseSpec(raw string) (color, size, advice string, ok bool) {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Count(trimmed, "【") != strings.Count(trimmed, "】") {
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实测「颜色,尺码」之间的逗号数恒为 1(6092 条 SKU 无例外)。
|
||||||
|
// 不是恰好一个逗号,说明格式超出了已知范围,不要猜哪个逗号才是分隔符。
|
||||||
|
if strings.Count(trimmed, ",") != 1 {
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(trimmed, ",", 2)
|
||||||
|
color = strings.TrimSpace(parts[0])
|
||||||
|
right := strings.TrimSpace(parts[1])
|
||||||
|
if color == "" || right == "" {
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
openIdx := strings.Index(right, "【")
|
||||||
|
closeIdx := strings.LastIndex(right, "】")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case openIdx >= 0 && closeIdx > openIdx:
|
||||||
|
// 「M【建議40-50公斤】」「L 【57.5/70公斤】」这类格式:
|
||||||
|
// 括号里是建议,括号外是尺码。
|
||||||
|
size = strings.TrimSpace(right[:openIdx])
|
||||||
|
advice = strings.TrimSpace(right[openIdx+len("【") : closeIdx])
|
||||||
|
advice = strings.TrimSpace(strings.TrimPrefix(advice, "建議"))
|
||||||
|
case strings.Contains(right, "建議"):
|
||||||
|
// 「L 建議50-57.5kg」这类没有括号、靠"建議"二字分隔的格式。
|
||||||
|
i := strings.Index(right, "建議")
|
||||||
|
size = strings.TrimSpace(right[:i])
|
||||||
|
advice = strings.TrimSpace(right[i+len("建議"):])
|
||||||
|
default:
|
||||||
|
// 「M」这种只有尺码、没有建议的格式。
|
||||||
|
size = right
|
||||||
|
}
|
||||||
|
|
||||||
|
if size == "" {
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
return color, size, advice, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
|
||||||
|
"cmautobuy/admin/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── ParseSpec ──────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestParseSpec(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
wantColor string
|
||||||
|
wantSize string
|
||||||
|
wantAdvice string
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "带【】的建议", raw: "黑色,M【建議40-50公斤】",
|
||||||
|
wantColor: "黑色", wantSize: "M", wantAdvice: "40-50公斤", wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "空格分隔的建议", raw: "卡其色拼黑色,L 建議50-57.5kg",
|
||||||
|
wantColor: "卡其色拼黑色", wantSize: "L", wantAdvice: "50-57.5kg", wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "只有颜色和尺码没有建议", raw: "黑色,M",
|
||||||
|
wantColor: "黑色", wantSize: "M", wantAdvice: "", wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "颜色自己也带【】", raw: "黑色 【夏裝單件T恤】,L 【57.5/70公斤】",
|
||||||
|
wantColor: "黑色 【夏裝單件T恤】", wantSize: "L", wantAdvice: "57.5/70公斤", wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 真实样本里的脏数据:缺左括号。见 ParseSpec 注释里"21 行括号
|
||||||
|
// 不配对怎么处理"一节——整体判定失败,不猜。
|
||||||
|
name: "括号不配对_缺左括号", raw: "紅色,3XL建議80-90公斤】",
|
||||||
|
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 真实样本里的另一种脏数据:缺右括号,而且用"寬鬆版"代替"建議"。
|
||||||
|
name: "括号不配对_缺右括号", raw: "粉色,3XL【寬鬆版 82.5-92.5kg",
|
||||||
|
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "完全认不出的格式_没有逗号", raw: "莫名其妙的格式",
|
||||||
|
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "两个逗号_超出已知范围", raw: "黑色,M,多余",
|
||||||
|
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "空字符串", raw: "",
|
||||||
|
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// raw 本身非空(trim 前有内容),但 trim 后为空——用来跟"空字符串"
|
||||||
|
// 那条区分开:如果 trimmed=="" 分支直接把 raw 当返回值,
|
||||||
|
// 这条能抓到(raw!=""),"空字符串"那条抓不到(raw 本身就是 "")。
|
||||||
|
name: "只有空白字符", raw: " ",
|
||||||
|
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 逗号右侧为空——只有颜色,没有尺码信息。
|
||||||
|
name: "逗号右侧为空", raw: "黑色,",
|
||||||
|
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 逗号左侧为空——只有尺码,没有颜色信息。
|
||||||
|
name: "逗号左侧为空", raw: ",M",
|
||||||
|
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 真实样本第 5454 行:右半整个被【】包住,括号外没有尺码文字,
|
||||||
|
// 提取后 size 为空。这是真实数据里 23 行失败中的 2 行之一,
|
||||||
|
// 直接用真实原文而不是自造用例,证明的是"这个真实格式我们
|
||||||
|
// 确实认不出来,而且认不出来时不猜",不只是代码逻辑自洽。
|
||||||
|
name: "右半整个被【】包住_size提取后为空", raw: "面膜安全褲3條膚色,【2xl 70-85公斤可穿】",
|
||||||
|
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
color, size, advice, ok := ParseSpec(tc.raw)
|
||||||
|
if ok != tc.wantOK {
|
||||||
|
t.Fatalf("ok = %v,想要 %v(raw=%q)", ok, tc.wantOK, tc.raw)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
// [必须] ok=false 时前三个必须全为空,不许出现"猜出来的一半"。
|
||||||
|
if color != "" || size != "" || advice != "" {
|
||||||
|
t.Fatalf("ok=false 时 color/size/advice 必须全为空,得到 %q/%q/%q",
|
||||||
|
color, size, advice)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if color != tc.wantColor || size != tc.wantSize || advice != tc.wantAdvice {
|
||||||
|
t.Fatalf("got %q/%q/%q,want %q/%q/%q",
|
||||||
|
color, size, advice, tc.wantColor, tc.wantSize, tc.wantAdvice)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 测试固件 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
// shopeeHeader 是真实报表的表头,只留导入用得到的 8 个业务列——
|
||||||
|
// 和真实报表一样按名字定位,列的相对顺序特意打乱一下(把 8 列之外的
|
||||||
|
// 顺序无所谓,这里保持简单,只放业务列)。
|
||||||
|
var shopeeHeader = []string{
|
||||||
|
"商品ID", "商品名稱", "商品當前狀態", "商品規格ID",
|
||||||
|
"商品規格", "規格當前狀態", "商品選項貨號", "主商品貨號",
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildShopeeWorkbook 现造一个 xlsx 固件,覆盖工单要求的场景:
|
||||||
|
// 正常行、三种规格格式、括号不配对、以及无关的广告报表 sheet
|
||||||
|
// (用来验证"按名字取 sheet,不用下标 0")。
|
||||||
|
//
|
||||||
|
// [必须] 测试不依赖 raw_data/,固件必须是现造或者提交进 admin/testdata/
|
||||||
|
// 的小文件,见工单 #38。这里选择现造:文件是二进制,提交进仓库不好 review。
|
||||||
|
func buildShopeeWorkbook(t *testing.T, header []string, rows [][]string) string {
|
||||||
|
t.Helper()
|
||||||
|
f := excelize.NewFile()
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// 默认 sheet 改名成一个无关的广告报表,模拟真实工作簿里
|
||||||
|
// "最佳表現商品"不是第 0 个 sheet 这种情况(虽然样本里恰好是,
|
||||||
|
// 但不能依赖这个巧合,见 ImportShopeeExcel 的实现和它的注释)。
|
||||||
|
f.SetSheetName("Sheet1", "追蹤商品廣告成效")
|
||||||
|
f.NewSheet("最佳表現商品")
|
||||||
|
|
||||||
|
if header != nil {
|
||||||
|
for col, name := range header {
|
||||||
|
cellRef, _ := excelize.CoordinatesToCellName(col+1, 1)
|
||||||
|
f.SetCellStr("最佳表現商品", cellRef, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for r, row := range rows {
|
||||||
|
for col, val := range row {
|
||||||
|
cellRef, _ := excelize.CoordinatesToCellName(col+1, r+2)
|
||||||
|
f.SetCellStr("最佳表現商品", cellRef, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "shopee_test.xlsx")
|
||||||
|
if err := f.SaveAs(path); err != nil {
|
||||||
|
t.Fatalf("保存测试固件失败: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// row 按 shopeeHeader 的列顺序拼一行,减少每个测试用例里的列对齐负担。
|
||||||
|
func row(goodsID, title, status, specID, spec, specStatus, skuCode, mainSKUCode string) []string {
|
||||||
|
return []string{goodsID, title, status, specID, spec, specStatus, skuCode, mainSKUCode}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ImportShopeeExcel ──────────────────────────────────
|
||||||
|
|
||||||
|
func TestImportShopeeExcel_基本导入(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
|
||||||
|
rows := [][]string{
|
||||||
|
// 商品 A:汇总行 + 3 种规格格式的 SKU 行
|
||||||
|
row("1001", "测试商品A", "正常", "-", "-", "", "", "货号A"),
|
||||||
|
row("1001", "测试商品A", "正常", "9001", "黑色,M【建議40-50公斤】", "正常", "sku-9001", "货号A"),
|
||||||
|
row("1001", "测试商品A", "正常", "9002", "卡其色拼黑色,L 建議50-57.5kg", "正常", "sku-9002", "货号A"),
|
||||||
|
row("1001", "测试商品A", "正常", "9003", "黑色,M", "正常", "sku-9003", "货号A"),
|
||||||
|
// 商品 B:汇总行 + 1 个解析失败的 SKU(括号不配对)
|
||||||
|
row("1002", "测试商品B", "正常", "-", "-", "", "", "货号B"),
|
||||||
|
row("1002", "测试商品B", "正常", "9004", "紅色,3XL建議80-90公斤】", "正常", "sku-9004", "货号B"),
|
||||||
|
}
|
||||||
|
path := buildShopeeWorkbook(t, shopeeHeader, rows)
|
||||||
|
|
||||||
|
result, err := ImportShopeeExcel(db, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("导入失败: %v", err)
|
||||||
|
}
|
||||||
|
if result.ProductCount != 2 {
|
||||||
|
t.Errorf("ProductCount = %d,想要 2", result.ProductCount)
|
||||||
|
}
|
||||||
|
if result.SKUCount != 4 {
|
||||||
|
t.Errorf("SKUCount = %d,想要 4", result.SKUCount)
|
||||||
|
}
|
||||||
|
if len(result.Failures) != 1 {
|
||||||
|
t.Fatalf("Failures 数量 = %d,想要 1(括号不配对那一行)", len(result.Failures))
|
||||||
|
}
|
||||||
|
if result.Failures[0].Raw != "紅色,3XL建議80-90公斤】" {
|
||||||
|
t.Errorf("失败行原文 = %q,不对", result.Failures[0].Raw)
|
||||||
|
}
|
||||||
|
if result.Failures[0].Row != 7 {
|
||||||
|
// 表头第 1 行 + 6 条数据行,"紅色..."是第 6 条数据 -> Excel 行号 7
|
||||||
|
t.Errorf("失败行行号 = %d,想要 7", result.Failures[0].Row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析失败的 SKU 仍然要写进库:spec_raw 照存,color/size/advice 为空,parse_ok=0。
|
||||||
|
var color, size, advice sql.NullString
|
||||||
|
var parseOK int
|
||||||
|
err = db.QueryRow(
|
||||||
|
`SELECT color, size, advice, parse_ok FROM shopee_skus WHERE sku_id = ?`, "9004",
|
||||||
|
).Scan(&color, &size, &advice, &parseOK)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("查询失败 SKU 出错: %v", err)
|
||||||
|
}
|
||||||
|
if color.Valid && color.String != "" {
|
||||||
|
t.Errorf("解析失败的行 color 应为空,得到 %q", color.String)
|
||||||
|
}
|
||||||
|
if parseOK != 0 {
|
||||||
|
t.Errorf("解析失败的行 parse_ok 应为 0,得到 %d", parseOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportShopeeExcel_错误sheet名(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
|
||||||
|
f := excelize.NewFile()
|
||||||
|
f.SetSheetName("Sheet1", "新上架商品")
|
||||||
|
f.NewSheet("高潛力廣告商品")
|
||||||
|
path := filepath.Join(t.TempDir(), "no_target_sheet.xlsx")
|
||||||
|
if err := f.SaveAs(path); err != nil {
|
||||||
|
t.Fatalf("保存测试固件失败: %v", err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
_, err := ImportShopeeExcel(db, path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("缺少「最佳表現商品」sheet 应该报错")
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
if !strings.Contains(msg, "最佳表現商品") || !strings.Contains(msg, "新上架商品") || !strings.Contains(msg, "高潛力廣告商品") {
|
||||||
|
t.Errorf("错误信息应列出实际有哪些 sheet,得到: %s", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportShopeeExcel_缺列(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
|
||||||
|
// 表头缺了「商品規格ID」,模拟蝦皮加/删列导致列错位的场景。
|
||||||
|
badHeader := []string{"商品ID", "商品名稱", "商品當前狀態", "商品規格", "規格當前狀態", "商品選項貨號", "主商品貨號"}
|
||||||
|
path := buildShopeeWorkbook(t, badHeader, nil)
|
||||||
|
|
||||||
|
_, err := ImportShopeeExcel(db, path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("缺少必需列应该报错")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "商品規格ID") {
|
||||||
|
t.Errorf("错误信息应说明缺的是哪一列,得到: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImportShopeeExcel_不覆盖人工字段 是工单 #38 里最要命的一条:
|
||||||
|
// 报表里没有 pdd_goods_url / pdd_goods_id 这两列,upsert 绝不能把它们
|
||||||
|
// 写成空值,否则操作员攒的 PDD 链接会被一次导入洗光,而且不报错。
|
||||||
|
func TestImportShopeeExcel_不覆盖人工字段(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
|
||||||
|
rows := [][]string{
|
||||||
|
row("2001", "测试商品C", "正常", "-", "-", "", "", "货号C"),
|
||||||
|
row("2001", "测试商品C", "正常", "8001", "黑色,M", "正常", "sku-8001", "货号C"),
|
||||||
|
}
|
||||||
|
path := buildShopeeWorkbook(t, shopeeHeader, rows)
|
||||||
|
|
||||||
|
// 先导入一次,让商品存在。
|
||||||
|
if _, err := ImportShopeeExcel(db, path); err != nil {
|
||||||
|
t.Fatalf("第一次导入失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟操作员手工填了 PDD 链接。
|
||||||
|
const manualURL = "https://mobile.yangkeduo.com/goods.html?goods_id=999999"
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`UPDATE shopee_products SET pdd_goods_url = ?, pdd_goods_id = ? WHERE goods_id = ?`,
|
||||||
|
manualURL, "999999", "2001",
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("模拟人工填链接失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再导入同一个文件(同一个商品名字也可能变了,模拟蝦皮改了标题)。
|
||||||
|
rows[0][1] = "测试商品C改名后"
|
||||||
|
rows[1][1] = "测试商品C改名后"
|
||||||
|
path2 := buildShopeeWorkbook(t, shopeeHeader, rows)
|
||||||
|
if _, err := ImportShopeeExcel(db, path2); err != nil {
|
||||||
|
t.Fatalf("第二次导入失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var gotURL, gotGoodsID, gotTitle sql.NullString
|
||||||
|
err := db.QueryRow(
|
||||||
|
`SELECT pdd_goods_url, pdd_goods_id, title FROM shopee_products WHERE goods_id = ?`, "2001",
|
||||||
|
).Scan(&gotURL, &gotGoodsID, &gotTitle)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("查询商品失败: %v", err)
|
||||||
|
}
|
||||||
|
if gotURL.String != manualURL {
|
||||||
|
t.Errorf("pdd_goods_url 被导入洗掉了:got %q, want %q", gotURL.String, manualURL)
|
||||||
|
}
|
||||||
|
if gotGoodsID.String != "999999" {
|
||||||
|
t.Errorf("pdd_goods_id 被导入洗掉了:got %q, want %q", gotGoodsID.String, "999999")
|
||||||
|
}
|
||||||
|
// 标题这类报表字段应该正常刷新,证明"不覆盖"只针对人工字段,不是整行不更新。
|
||||||
|
if gotTitle.String != "测试商品C改名后" {
|
||||||
|
t.Errorf("title 应该被报表里的新值刷新:got %q", gotTitle.String)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImportShopeeExcel_不删除人工新增SKU 对应「05」§4.4 提到的人工新增 SKU
|
||||||
|
// (本工单不实现新增入口,但导入必须留出这个口子,不能做成
|
||||||
|
// "先 DELETE 再 INSERT" 的全量替换)。
|
||||||
|
func TestImportShopeeExcel_不删除人工新增SKU(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
|
||||||
|
rows := [][]string{
|
||||||
|
row("3001", "测试商品D", "正常", "-", "-", "", "", "货号D"),
|
||||||
|
row("3001", "测试商品D", "正常", "7001", "黑色,M", "正常", "sku-7001", "货号D"),
|
||||||
|
}
|
||||||
|
path := buildShopeeWorkbook(t, shopeeHeader, rows)
|
||||||
|
if _, err := ImportShopeeExcel(db, path); err != nil {
|
||||||
|
t.Fatalf("导入失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟操作员手动新增一个报表里没有的 SKU。
|
||||||
|
now := model.NowISO()
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
INSERT INTO shopee_skus (sku_id, goods_id, spec_raw, color, size, advice, parse_ok, sku_code, is_manual, created_at, updated_at)
|
||||||
|
VALUES ('manual-1', '3001', '手动新增-均码', '均码', '', '', 1, '', 1, ?, ?)`,
|
||||||
|
now, now); err != nil {
|
||||||
|
t.Fatalf("插入人工新增 SKU 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再导入同一个文件。
|
||||||
|
if _, err := ImportShopeeExcel(db, path); err != nil {
|
||||||
|
t.Fatalf("第二次导入失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isManual int
|
||||||
|
err := db.QueryRow(`SELECT is_manual FROM shopee_skus WHERE sku_id = 'manual-1'`).Scan(&isManual)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("人工新增的 SKU 被导入删掉了: %v", err)
|
||||||
|
}
|
||||||
|
if isManual != 1 {
|
||||||
|
t.Errorf("is_manual 应该还是 1,得到 %d", isManual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportShopeeExcel_二次导入不翻倍(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
|
||||||
|
rows := [][]string{
|
||||||
|
row("4001", "测试商品E", "正常", "-", "-", "", "", "货号E"),
|
||||||
|
row("4001", "测试商品E", "正常", "6001", "黑色,M", "正常", "sku-6001", "货号E"),
|
||||||
|
row("4001", "测试商品E", "正常", "6002", "白色,L", "正常", "sku-6002", "货号E"),
|
||||||
|
}
|
||||||
|
path := buildShopeeWorkbook(t, shopeeHeader, rows)
|
||||||
|
|
||||||
|
first, err := ImportShopeeExcel(db, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("第一次导入失败: %v", err)
|
||||||
|
}
|
||||||
|
second, err := ImportShopeeExcel(db, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("第二次导入失败: %v", err)
|
||||||
|
}
|
||||||
|
if first.ProductCount != second.ProductCount || first.SKUCount != second.SKUCount {
|
||||||
|
t.Fatalf("两次导入统计不一致: %+v vs %+v", first, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
var productCount, skuCount int
|
||||||
|
db.QueryRow(`SELECT COUNT(*) FROM shopee_products WHERE goods_id = '4001'`).Scan(&productCount)
|
||||||
|
db.QueryRow(`SELECT COUNT(*) FROM shopee_skus WHERE goods_id = '4001'`).Scan(&skuCount)
|
||||||
|
if productCount != 1 {
|
||||||
|
t.Errorf("shopee_products 应该只有 1 行,得到 %d(重复导入产生了重复行)", productCount)
|
||||||
|
}
|
||||||
|
if skuCount != 2 {
|
||||||
|
t.Errorf("shopee_skus 应该只有 2 行,得到 %d(重复导入产生了重复行)", skuCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// 蝦皮数据列表页的查询和界面文字组装。
|
||||||
|
//
|
||||||
|
// 分成单独文件是因为它和 shopee_import.go 职责不同:这里只读,不写库。
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"cmautobuy/admin/model"
|
||||||
|
"cmautobuy/admin/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShopeeSKUView 是列表页一行要显示的全部内容,全部已经是字符串,
|
||||||
|
// 模板里不做判断和格式化。
|
||||||
|
type ShopeeSKUView struct {
|
||||||
|
GoodsID string
|
||||||
|
Title string
|
||||||
|
SKUID string
|
||||||
|
|
||||||
|
Color string
|
||||||
|
Size string
|
||||||
|
Advice string
|
||||||
|
// ParseFailed 为 true 时整行标黄,提示需要人工补颜色/尺码/建议,
|
||||||
|
// 见 docs/admin/05-ui-specification.md §4.2。
|
||||||
|
ParseFailed bool
|
||||||
|
|
||||||
|
// PddURL 为空且 PddMissing 为 true 时,模板显示"未填写"并标红——
|
||||||
|
// 这是最需要操作员注意的状态,见 §4.2。
|
||||||
|
PddURL string
|
||||||
|
PddMissing bool
|
||||||
|
|
||||||
|
StatusText string
|
||||||
|
IsManual bool
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShopeeListResult 是列表页要的全部数据。
|
||||||
|
type ShopeeListResult struct {
|
||||||
|
Rows []ShopeeSKUView
|
||||||
|
Total int // shopee_products 的总商品数(不受筛选影响),用于判断"是否已导入过"
|
||||||
|
IsFiltered bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListShopeeSKUs 查蝦皮 SKU 列表并把每一行翻成界面文字。
|
||||||
|
//
|
||||||
|
// 采集状态来自 pdd_products(联查得到),不是蝦皮自己的字段,
|
||||||
|
// 见 docs/admin/01-requirements.md §6.1:
|
||||||
|
//
|
||||||
|
// shopee_products.pdd_goods_id 为空 -> "未填链接"
|
||||||
|
// pdd_products.collect_status -> 未采集 / 采集中 / 已采集 / 采集失败
|
||||||
|
func ListShopeeSKUs(db *sql.DB, keyword string) (*ShopeeListResult, error) {
|
||||||
|
rows, err := repository.ListShopeeSKUs(db, keyword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
total, err := repository.CountShopeeProducts(db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &ShopeeListResult{
|
||||||
|
Rows: make([]ShopeeSKUView, 0, len(rows)),
|
||||||
|
Total: total,
|
||||||
|
IsFiltered: keyword != "",
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
v := ShopeeSKUView{
|
||||||
|
GoodsID: r.GoodsID,
|
||||||
|
Title: r.ProductTitle,
|
||||||
|
SKUID: r.SKUID,
|
||||||
|
IsManual: r.IsManual,
|
||||||
|
UpdatedAt: formatLocalTime(r.UpdatedAt),
|
||||||
|
}
|
||||||
|
if r.ParseOK {
|
||||||
|
v.Color, v.Size, v.Advice = r.Color, r.Size, r.Advice
|
||||||
|
if v.Advice == "" {
|
||||||
|
v.Advice = placeholder
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
v.Color, v.Size, v.Advice = placeholder, placeholder, placeholder
|
||||||
|
v.ParseFailed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.PddGoodsID == "" {
|
||||||
|
v.PddMissing = true
|
||||||
|
v.PddURL = "未填写"
|
||||||
|
v.StatusText = "未填链接"
|
||||||
|
} else {
|
||||||
|
v.PddURL = r.PddGoodsURL
|
||||||
|
if r.CollectStatus.Valid {
|
||||||
|
v.StatusText = collectStatusText(model.CollectStatus(r.CollectStatus.String))
|
||||||
|
} else {
|
||||||
|
// pdd_goods_id 有值但 pdd_products 里查不到对应行
|
||||||
|
// (比如那条 PDD 商品被软删除了),如实说明,不要装作"未采集"。
|
||||||
|
v.StatusText = "PDD 商品缺失"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Rows = append(result.Rows, v)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -45,13 +45,26 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Rows}}
|
{{range .Rows}}
|
||||||
{{/* TODO(骨架): 行渲染。解析失败的行整行标黄(class="row-warn"),
|
<tr{{if .ParseFailed}} class="row-warn"{{end}} title="SKU {{.SKUID}}">
|
||||||
PDD 链接为空的显示"未填写"并标红。双击行打开编辑弹窗。 */}}
|
<td class="col-check"><input type="checkbox" name="ids" value="{{.GoodsID}}"></td>
|
||||||
|
<td>{{.GoodsID}}</td>
|
||||||
|
<td title="{{.Title}}">{{.Title}}{{if .IsManual}} <small>(人工新增)</small>{{end}}</td>
|
||||||
|
<td>{{.Color}}</td>
|
||||||
|
<td>{{.Size}}</td>
|
||||||
|
<td>{{.Advice}}</td>
|
||||||
|
<td{{if .PddMissing}} class="missing"{{end}}>{{.PddURL}}</td>
|
||||||
|
<td>{{.StatusText}}</td>
|
||||||
|
<td>{{.UpdatedAt}}</td>
|
||||||
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr class="empty">
|
<tr class="empty">
|
||||||
<td colspan="9">
|
<td colspan="9">
|
||||||
|
{{if .IsFiltered}}
|
||||||
|
没有匹配的数据,换个商品 ID 试试。
|
||||||
|
{{else}}
|
||||||
还没有数据,点左上角「导入 Excel」开始。<br>
|
还没有数据,点左上角「导入 Excel」开始。<br>
|
||||||
<small>样本文件不在仓库里,需向项目负责人索取,放到 raw_data/ 下。</small>
|
<small>样本文件不在仓库里,需向项目负责人索取,放到 raw_data/ 下。</small>
|
||||||
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -59,6 +72,31 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{/* ── 导入失败行:全部列出来,不折叠、不只显示条数 ──── */}}
|
||||||
|
{{if .Failures}}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<p>本次导入有 {{len .Failures}} 行解析失败,规格原文已按原样保存,需人工补颜色/尺码/建议:</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>行号</th>
|
||||||
|
<th>规格原文</th>
|
||||||
|
<th>原因</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Failures}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Row}}</td>
|
||||||
|
<td>{{.Raw}}</td>
|
||||||
|
<td>{{.Reason}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{/* TODO(骨架): 编辑弹窗 templates/shopee/edit_modal.html
|
{{/* TODO(骨架): 编辑弹窗 templates/shopee/edit_modal.html
|
||||||
这是从蝦皮商品出发录入 PDD 链接、发起采集的入口。
|
这是从蝦皮商品出发录入 PDD 链接、发起采集的入口。
|
||||||
采集按钮**在本页只出现在这个弹窗里**——不要放到表格每一行,
|
采集按钮**在本页只出现在这个弹窗里**——不要放到表格每一行,
|
||||||
|
|||||||
Vendored
+19
-24
@@ -3,37 +3,32 @@
|
|||||||
自动化测试用的**脱敏小样本**放这里,**要提交进 Git**。
|
自动化测试用的**脱敏小样本**放这里,**要提交进 Git**。
|
||||||
否则别人拉下仓库跑 `go test ./...` 会失败。
|
否则别人拉下仓库跑 `go test ./...` 会失败。
|
||||||
|
|
||||||
## 需要准备的文件
|
## 蝦皮 Excel 导入测试(工单 #38)
|
||||||
|
|
||||||
| 文件 | 用途 | 状态 |
|
**没有提交静态的 `shopee_sample.xlsx`。** 固件改成用 `excelize` 在
|
||||||
|---|---|---|
|
`admin/service/shopee_import_test.go` 里现造(见 `buildShopeeWorkbook`),
|
||||||
| `shopee_sample.xlsx` | Excel 导入测试 | **待制作** |
|
测试跑到时临时生成、测试结束自动清理。
|
||||||
|
|
||||||
## 怎么制作 `shopee_sample.xlsx`
|
选择现造而不是提交一个二进制文件的原因:xlsx 是二进制格式,PR 里看不出
|
||||||
|
改了什么,审查只能"信任"文件内容;现造的话,固件长什么样直接写在
|
||||||
|
测试代码里,一眼能看到覆盖了哪些场景。
|
||||||
|
|
||||||
从真实报表(`raw_data/蝦皮数据样本.xlsx`,需向项目负责人索取)里裁出来:
|
覆盖的场景(对应工单 #38 的要求):
|
||||||
|
|
||||||
1. **只保留几十行**,但要覆盖这几种情况:
|
|
||||||
- 商品汇总行(`商品規格ID` 是 `-`);
|
- 商品汇总行(`商品規格ID` 是 `-`);
|
||||||
- SKU 行,规格原文用**带【】**的格式,如 `黑色,M【建議40-50公斤】`;
|
- SKU 行,规格原文用**带【】**的格式,如 `黑色,M【建議40-50公斤】`;
|
||||||
- SKU 行,规格原文用**空格分隔**的格式,如 `卡其色拼黑色,L 建議50-57.5kg`;
|
- SKU 行,规格原文用**空格分隔**的格式,如 `卡其色拼黑色,L 建議50-57.5kg`;
|
||||||
- 至少一行**故意写成解析不了的格式**,用来验证 `parse_ok=0` 且不瞎猜。
|
- SKU 行,只有颜色和尺码、没有建议,如 `黑色,M`;
|
||||||
|
- 颜色自己也带【】的格式,如 `黑色 【夏裝單件T恤】,L 【57.5/70公斤】`;
|
||||||
|
- 括号不配对的脏数据(真实样本里的 21~23 行),验证 `parse_ok=0`
|
||||||
|
且不瞎猜;
|
||||||
|
- 表头缺列、sheet 名不对;
|
||||||
|
- 已有人工填的 `pdd_goods_url` / `pdd_goods_id`,验证导入不覆盖;
|
||||||
|
- 已有 `is_manual = 1` 的人工新增 SKU,验证导入不删除;
|
||||||
|
- 同一个文件导入两次,验证不产生重复行。
|
||||||
|
|
||||||
前两种在真实数据里各占 53.4% 和 46.6%,两种都必须有用例。
|
真实样本 `raw_data/蝦皮数据样本.xlsx`(需向项目负责人索取,
|
||||||
|
含商业数据,已在 `.gitignore` 里排除)仍然用于**人工验证**,
|
||||||
2. **删掉全部商业指标列**——销售额、曝光次数、点击数、转化率、
|
不用于自动化测试——1.9MB,每次跑测试都读一遍太慢。
|
||||||
买家数、回购率等等。导入根本用不到这些,留着就是泄露。
|
|
||||||
|
|
||||||
只保留:`商品ID`、`商品名稱`、`商品當前狀態`、`商品規格ID`、
|
|
||||||
`商品規格`、`規格當前狀態`、`商品選項貨號`、`主商品貨號`。
|
|
||||||
|
|
||||||
3. **商品名称换成无意义的占位文字**,如 `测试商品A`、`测试商品B`。
|
|
||||||
|
|
||||||
4. 记下期望结果,写进测试断言(例如 3 商品 + 8 SKU + 1 行解析失败)。
|
|
||||||
|
|
||||||
## 为什么不直接用真实报表
|
|
||||||
|
|
||||||
- 1.9MB,每次跑测试都读一遍太慢;
|
|
||||||
- 含逐商品的台币销售额,属于商业数据,已在 `.gitignore` 里排除。
|
|
||||||
|
|
||||||
相关规定见 `docs/admin/06-quality-security.md` §2.1。
|
相关规定见 `docs/admin/06-quality-security.md` §2.1。
|
||||||
|
|||||||
Reference in New Issue
Block a user