feat: 顺运宝支持输入 PDD 商品 ID (#95)
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cmautobuy/admin/model"
|
||||||
"cmautobuy/admin/repository"
|
"cmautobuy/admin/repository"
|
||||||
"cmautobuy/admin/service"
|
"cmautobuy/admin/service"
|
||||||
)
|
)
|
||||||
@@ -127,6 +128,33 @@ func TestMainPagesReturnOK(t *testing.T) {
|
|||||||
t.Error("顺运宝页面不应再显示旧的指定日期同步入口")
|
t.Error("顺运宝页面不应再显示旧的指定日期同步入口")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, err = repository.UpsertSybOrder(db, model.SybOrder{
|
||||||
|
SybID: "SYB-PDD-ID-INPUT", OrderNo: "ORDER-PDD-ID-INPUT", Title: "输入测试商品",
|
||||||
|
ProductSpec: "黑色,M", ShopeeGoodsID: "SP-PDD-ID-INPUT", Quantity: 1, SybData: `{}`,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("准备顺运宝详情测试数据失败: %v", err)
|
||||||
|
}
|
||||||
|
detailRequest := httptest.NewRequest(http.MethodGet, "/syb/detail?id=SYB-PDD-ID-INPUT", nil)
|
||||||
|
addAuth(detailRequest)
|
||||||
|
detailResponse := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(detailResponse, detailRequest)
|
||||||
|
detailBody := detailResponse.Body.String()
|
||||||
|
if detailResponse.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /syb/detail = %d,响应:%s", detailResponse.Code, detailBody)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"PDD 商品链接或商品 ID", `type="text" name="pdd_url"`,
|
||||||
|
"6~24 位纯数字商品 ID",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(detailBody, want) {
|
||||||
|
t.Errorf("顺运宝详情输入区缺少 %q,响应:%s", want, detailBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(detailBody, `type="url" name="pdd_url"`) {
|
||||||
|
t.Error("顺运宝详情不应继续使用会拦截纯数字的 URL 输入类型")
|
||||||
|
}
|
||||||
|
|
||||||
partialRequest := httptest.NewRequest(http.MethodGet,
|
partialRequest := httptest.NewRequest(http.MethodGet,
|
||||||
"/syb/sync-history?history_page=1&order_no=ORDER-1&date_from=2026-08-01&date_to=2026-08-09", nil)
|
"/syb/sync-history?history_page=1&order_no=ORDER-1&date_from=2026-08-01&date_to=2026-08-09", nil)
|
||||||
addAuth(partialRequest)
|
addAuth(partialRequest)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -9,6 +10,86 @@ import (
|
|||||||
"cmautobuy/admin/repository"
|
"cmautobuy/admin/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestAssociateSybPdd_兼容商品ID并复用已有采集结果(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
seedWorkflowOrder(t, db, "SYB-PDD-ID", "SP-PDD-ID", "黑色,M")
|
||||||
|
|
||||||
|
if _, err := repository.EnsurePddProduct(db, "737116531267", pddURLA); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repository.SetCollectResult(db, "737116531267", "已采集商品", "已有店铺", collectedThreeDimensions); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
goodsID, err := AssociateSybPdd(db, "SYB-PDD-ID", " 737116531267 ", false)
|
||||||
|
if err != nil || goodsID != "737116531267" {
|
||||||
|
t.Fatalf("纯商品 ID 关联结果 goodsID=%q err=%v", goodsID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
product, err := repository.GetPddProductByGoodsID(db, goodsID)
|
||||||
|
if err != nil || product == nil {
|
||||||
|
t.Fatalf("读取复用商品失败: product=%+v err=%v", product, err)
|
||||||
|
}
|
||||||
|
if product.URL != pddURLA || product.CollectStatus != model.CollectCollected ||
|
||||||
|
product.Title != "已采集商品" || product.SkusJSON != collectedThreeDimensions {
|
||||||
|
t.Fatalf("已有采集结果不应被清空或重置: %+v", product)
|
||||||
|
}
|
||||||
|
var productCount, taskCount int
|
||||||
|
db.QueryRow(`SELECT COUNT(*) FROM pdd_products WHERE goods_id = ?`, goodsID).Scan(&productCount)
|
||||||
|
db.QueryRow(`SELECT COUNT(*) FROM tasks`).Scan(&taskCount)
|
||||||
|
if productCount != 1 || taskCount != 0 {
|
||||||
|
t.Fatalf("应复用一条商品且不自动建任务: products=%d tasks=%d", productCount, taskCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssociateSybPdd_新商品ID生成标准链接且完整链接保持兼容(t *testing.T) {
|
||||||
|
t.Run("纯商品ID", func(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
seedWorkflowOrder(t, db, "SYB-NEW-ID", "SP-NEW-ID", "白色,L")
|
||||||
|
goodsID, err := AssociateSybPdd(db, "SYB-NEW-ID", "123456789", false)
|
||||||
|
if err != nil || goodsID != "123456789" {
|
||||||
|
t.Fatalf("关联结果 goodsID=%q err=%v", goodsID, err)
|
||||||
|
}
|
||||||
|
product, _ := repository.GetPddProductByGoodsID(db, goodsID)
|
||||||
|
if product == nil || product.URL != canonicalPddGoodsURL+goodsID || product.CollectStatus != model.CollectPending {
|
||||||
|
t.Fatalf("新商品 ID 没有生成待采集标准档案: %+v", product)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("完整链接", func(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
seedWorkflowOrder(t, db, "SYB-FULL-URL", "SP-FULL-URL", "白色,L")
|
||||||
|
goodsID, err := AssociateSybPdd(db, "SYB-FULL-URL", pddURLA, false)
|
||||||
|
if err != nil || goodsID != "737116531267" {
|
||||||
|
t.Fatalf("原完整链接行为回归: goodsID=%q err=%v", goodsID, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssociateSybPdd_商品ID输入边界和换品确认(t *testing.T) {
|
||||||
|
for _, input := range []string{"12345", "1234567890123456789012345", "737116abc", "普通文本"} {
|
||||||
|
t.Run(input, func(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
seedWorkflowOrder(t, db, "SYB-BAD-ID", "SP-BAD-ID", "黑色,M")
|
||||||
|
if _, err := AssociateSybPdd(db, "SYB-BAD-ID", input, false); !errors.Is(err, ErrBadPddURL) {
|
||||||
|
t.Fatalf("输入 %q 应返回 ErrBadPddURL,实际 %v", input, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
db := newTestDB(t)
|
||||||
|
seedWorkflowOrder(t, db, "SYB-REPLACE", "SP-REPLACE", "黑色,M")
|
||||||
|
if _, err := AssociateSybPdd(db, "SYB-REPLACE", "737116531267", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := AssociateSybPdd(db, "SYB-REPLACE", "937122477375", false); !errors.Is(err, ErrPddAssociationReplaceRequired) {
|
||||||
|
t.Fatalf("纯 ID 换成其他商品仍应要求确认,实际 %v", err)
|
||||||
|
}
|
||||||
|
if _, err := AssociateSybPdd(db, "SYB-REPLACE", "937122477375", true); err != nil {
|
||||||
|
t.Fatalf("确认后应允许更换: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const collectedThreeDimensions = `{
|
const collectedThreeDimensions = `{
|
||||||
"goods_id":"737116531267",
|
"goods_id":"737116531267",
|
||||||
"price_granularity":"sku",
|
"price_granularity":"sku",
|
||||||
|
|||||||
@@ -12,6 +12,36 @@ import (
|
|||||||
// ErrPddAssociationReplaceRequired 表示当前蝦皮商品已经关联了另一个 PDD 商品。
|
// ErrPddAssociationReplaceRequired 表示当前蝦皮商品已经关联了另一个 PDD 商品。
|
||||||
var ErrPddAssociationReplaceRequired = errors.New("更换 PDD 商品需要明确确认")
|
var ErrPddAssociationReplaceRequired = errors.New("更换 PDD 商品需要明确确认")
|
||||||
|
|
||||||
|
const canonicalPddGoodsURL = "https://mobile.yangkeduo.com/goods.html?goods_id="
|
||||||
|
|
||||||
|
// normalizeSybPddReference 只在顺运宝详情入口兼容纯商品 ID。
|
||||||
|
// 其他入口仍沿用完整 PDD 链接校验,避免无意扩大已经确认的输入范围。
|
||||||
|
func normalizeSybPddReference(raw string) (string, error) {
|
||||||
|
value := strings.TrimSpace(raw)
|
||||||
|
if value == "" {
|
||||||
|
return "", fmt.Errorf("%w: PDD 商品链接或商品 ID 不能为空", ErrBadPddURL)
|
||||||
|
}
|
||||||
|
if isGoodsID(value) {
|
||||||
|
return canonicalPddGoodsURL + value, nil
|
||||||
|
}
|
||||||
|
if isASCIIDigits(value) {
|
||||||
|
return "", fmt.Errorf("%w: PDD 商品 ID 必须是 6~24 位纯数字", ErrBadPddURL)
|
||||||
|
}
|
||||||
|
if !strings.Contains(value, "://") {
|
||||||
|
return "", fmt.Errorf("%w: 请输入带 goods_id 的完整拼多多链接,或 6~24 位纯数字商品 ID", ErrBadPddURL)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isASCIIDigits(value string) bool {
|
||||||
|
for _, char := range value {
|
||||||
|
if char < '0' || char > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// AssociateShopeePdd 把蝦皮商品关联到 PDD 商品档案。
|
// AssociateShopeePdd 把蝦皮商品关联到 PDD 商品档案。
|
||||||
// 解析、建档/复活和关联更新在同一事务内完成,任一步失败都不留下半成品。
|
// 解析、建档/复活和关联更新在同一事务内完成,任一步失败都不留下半成品。
|
||||||
func AssociateShopeePdd(db *sql.DB, shopeeGoodsID, rawURL string, confirmReplace bool) (string, error) {
|
func AssociateShopeePdd(db *sql.DB, shopeeGoodsID, rawURL string, confirmReplace bool) (string, error) {
|
||||||
@@ -81,7 +111,11 @@ func AssociateSybPdd(db *sql.DB, sybID, rawURL string, confirmReplace bool) (str
|
|||||||
if context.Order.SpecKey == "" {
|
if context.Order.SpecKey == "" {
|
||||||
return "", fmt.Errorf("顺运宝未提供规格,不能进入采购流程")
|
return "", fmt.Errorf("顺运宝未提供规格,不能进入采购流程")
|
||||||
}
|
}
|
||||||
return AssociateShopeePdd(db, context.Order.ShopeeGoodsID, rawURL, confirmReplace)
|
normalizedURL, err := normalizeSybPddReference(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return AssociateShopeePdd(db, context.Order.ShopeeGoodsID, normalizedURL, confirmReplace)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSybPddCollectTask 按顺运宝明细当前关联创建采集任务。
|
// CreateSybPddCollectTask 按顺运宝明细当前关联创建采集任务。
|
||||||
|
|||||||
@@ -27,9 +27,9 @@
|
|||||||
<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-url">PDD 商品链接</label>
|
<label for="syb-pdd-url">PDD 商品链接或商品 ID</label>
|
||||||
<input id="syb-pdd-url" type="url" name="pdd_url" value="{{.PddURL}}" required placeholder="https://mobile.yangkeduo.com/goods.html?goods_id=...">
|
<input id="syb-pdd-url" type="text" name="pdd_url" value="{{.PddURL}}" required placeholder="完整拼多多链接,或 737116531267">
|
||||||
<small>{{if .PddGoodsID}}当前 PDD 商品 ID:{{.PddGoodsID}}{{else}}保存后会从完整链接解析商品 ID,并建立 PDD 商品档案。{{end}}</small>
|
<small>{{if .PddGoodsID}}当前 PDD 商品 ID:{{.PddGoodsID}}。可以粘贴完整拼多多链接或 6~24 位纯数字商品 ID。{{else}}可以粘贴带 goods_id 的完整拼多多链接,或直接输入 6~24 位纯数字商品 ID。{{end}}</small>
|
||||||
</div>
|
</div>
|
||||||
{{if .PddGoodsID}}
|
{{if .PddGoodsID}}
|
||||||
<label class="check-line"><input type="checkbox" name="confirm_replace" value="1">如果新链接是其他商品,我确认更换当前关联</label>
|
<label class="check-line"><input type="checkbox" name="confirm_replace" value="1">如果新链接是其他商品,我确认更换当前关联</label>
|
||||||
|
|||||||
@@ -209,7 +209,9 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
|||||||
|
|
||||||
- 顺运宝的 `product_spec` 是匹配主链路的真实来源;系统不再要求先确认蝦皮 SKU。
|
- 顺运宝的 `product_spec` 是匹配主链路的真实来源;系统不再要求先确认蝦皮 SKU。
|
||||||
- “规格身份键” `spec_key` 只对顺运宝规格原文去除首尾空白、折叠连续空白,用于判断是否是同一条规格;颜色、尺寸和建议的解析只用于显示与推荐,不改变身份。
|
- “规格身份键” `spec_key` 只对顺运宝规格原文去除首尾空白、折叠连续空白,用于判断是否是同一条规格;颜色、尺寸和建议的解析只用于显示与推荐,不改变身份。
|
||||||
- 找到蝦皮商品后,可在同一弹窗填写完整 PDD 链接并创建采集任务;链接关联规则与蝦皮详情完全一致。
|
- 找到蝦皮商品后,可在同一弹窗填写带 `goods_id` 的完整 PDD 链接,或直接输入
|
||||||
|
6~24 位纯数字 PDD 商品 ID;纯 ID 先转换为标准商品链接,再复用现有 PDD 商品档案和
|
||||||
|
采集结果。关联完成后仍由采购员另行创建采集任务,换成其他商品仍须明确确认。
|
||||||
|
|
||||||
- 显示蝦皮规格原文和当前 PDD 商品 `skus_json` 里的动态规格组合与人民币价格;
|
- 显示蝦皮规格原文和当前 PDD 商品 `skus_json` 里的动态规格组合与人民币价格;
|
||||||
- PDD 维度来自采集结果的 `dimensions`,不得写死成颜色、尺码两个维度;
|
- PDD 维度来自采集结果的 `dimensions`,不得写死成颜色、尺码两个维度;
|
||||||
|
|||||||
@@ -490,8 +490,10 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
|
|||||||
弹窗按当前处理阶段渐进展示。它直接以顺运宝规格原文及其 `spec_key`
|
弹窗按当前处理阶段渐进展示。它直接以顺运宝规格原文及其 `spec_key`
|
||||||
连接当前 PDD 商品选项,不再经过蝦皮 SKU 确认步骤。PDD 关联、采集和规格映射使用同一个弹窗完成。
|
连接当前 PDD 商品选项,不再经过蝦皮 SKU 确认步骤。PDD 关联、采集和规格映射使用同一个弹窗完成。
|
||||||
|
|
||||||
弹窗先展示当前 PDD 商品 ID、完整链接和采集状态。未关联时
|
弹窗先展示当前 PDD 商品 ID、完整链接和采集状态。关联输入框使用可见标签
|
||||||
可直接填写完整链接;换成其他商品需要明确确认。待采集或采集失败时显示
|
“PDD 商品链接或商品 ID”和持续帮助文字,允许填写带 `goods_id` 的完整链接,或
|
||||||
|
6~24 位纯数字商品 ID;不能使用会拦截纯数字的 `type=url`。纯 ID 由服务端转换为
|
||||||
|
标准商品链接并按 `goods_id` 复用已有档案;换成其他商品需要明确确认。待采集或采集失败时显示
|
||||||
“创建采集任务”或“重新创建采集任务”,采集中和已采集状态只显示说明。
|
“创建采集任务”或“重新创建采集任务”,采集中和已采集状态只显示说明。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
Reference in New Issue
Block a user