feat: 完成规格映射与采购任务创建 (#69)

This commit is contained in:
chengma
2026-08-09 22:55:14 +08:00
parent 610e9e3749
commit 4433e3af37
18 changed files with 976 additions and 135 deletions
+57 -6
View File
@@ -67,6 +67,11 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
"读取顺运宝同步记录失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
return
}
assignableClients, err := service.ListAssignableClients(h.db, currentUser(c), h.onlineThreshold)
if err != nil {
fail(c, http.StatusInternalServerError, "读取可分配客户端失败,数据没有被改动。")
return
}
syncStatus := service.GetSybSyncStatus()
status := msg
@@ -177,8 +182,9 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
keyword, dateFrom, dateTo),
"HistoryFetchPagination": sybHistoryPartialPagination(history.Page, history.TotalPages,
keyword, dateFrom, dateTo),
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
"DetailURL": "/syb/detail?" + detailValues.Encode(),
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
"DetailURL": "/syb/detail?" + detailValues.Encode(),
"AssignableClients": assignableClients,
}))
}
@@ -592,8 +598,16 @@ func (h *Handler) SybCollectPdd(c *gin.Context) {
// 不是这一张订单的临时数据。下次遇到同一个蝦皮 SKU 自动带出,
// 操作员只需确认。见 docs/admin/03-data-model.md §5。
func (h *Handler) SybMatch(c *gin.Context) {
// TODO(骨架): upsert sku_mappings
fail(c, http.StatusNotImplemented, "规格匹配尚未实现。")
actor := currentUser(c)
operator := ""
if actor != nil {
operator = actor.UserID
}
if err := service.SaveSybMapping(h.db, c.PostForm("syb_id"), c.PostForm("pdd_option_key"), operator); err != nil {
h.sybRedirect(c, "规格映射未保存:"+err.Error())
return
}
h.sybRedirect(c, "规格映射已保存;相同蝦皮规格和 PDD 商品会自动复用。")
}
// SybCreateTask 由勾选的货运单创建采购任务。
@@ -608,8 +622,45 @@ func (h *Handler) SybMatch(c *gin.Context) {
//
// 校验不过的**不要静默跳过**,要列出来告诉操作员缺什么。
func (h *Handler) SybCreateTask(c *gin.Context) {
// TODO(骨架): 调 service.CreatePurchaseTasks(sybIDs, clientID)
fail(c, http.StatusNotImplemented, "创建采购任务尚未实现。")
ids := c.PostFormArray("ids")
prices := c.PostFormArray("max_price_yuan")
if len(ids) == 0 {
h.sybRedirect(c, "没有选择货运单明细,没有创建采购任务。")
return
}
requests := make([]service.PurchaseTaskRequest, 0, len(ids))
preFailures := make([]service.TaskCreateError, 0)
for i, sybID := range ids {
if i >= len(prices) {
preFailures = append(preFailures, service.TaskCreateError{SybID: sybID, Reason: "人民币价格上限不能为空"})
continue
}
cent, err := service.ParsePriceYuanToCent(prices[i])
if err != nil {
preFailures = append(preFailures, service.TaskCreateError{SybID: sybID, Reason: err.Error()})
continue
}
requests = append(requests, service.PurchaseTaskRequest{SybID: sybID, MaxPriceCent: cent})
}
result, err := service.CreatePurchaseTasks(h.db, currentUser(c), requests, c.PostForm("client_id"))
if err != nil {
h.sybRedirect(c, "采购任务没有创建:"+err.Error())
return
}
result.Failures = append(preFailures, result.Failures...)
h.sybRedirect(c, purchaseTaskResultMessage(result))
}
func purchaseTaskResultMessage(result service.PurchaseTaskResult) string {
parts := []string{fmt.Sprintf("已创建 %d 个采购任务", result.Created)}
if len(result.Failures) > 0 {
failures := make([]string, 0, len(result.Failures))
for _, failure := range result.Failures {
failures = append(failures, failure.SybID+"("+failure.Reason+")")
}
parts = append(parts, "未创建:"+strings.Join(failures, ";"))
}
return strings.Join(parts, "。")
}
// SybDelete 批量删除货运单。
+20
View File
@@ -184,6 +184,26 @@ func ListClientsForUser(db *sql.DB, keyword, visibleUserID string) ([]ClientWith
return listClientsForUserPage(db, keyword, visibleUserID, -1, 0)
}
// ClientVisibleToUser 校验客户端是否存在且位于当前账号可见范围。
// visibleUserID 为空表示管理员,可选择任意已登记客户端。
func ClientVisibleToUser(q Execer, clientID, visibleUserID string) (bool, error) {
query := `SELECT COUNT(*) FROM clients c`
args := []any{}
if visibleUserID != "" {
query += ` JOIN client_user_assignments a
ON a.client_id = c.client_id AND a.ended_at IS NULL
AND a.user_id = ?`
args = append(args, visibleUserID)
}
query += ` WHERE c.client_id = ?`
args = append(args, clientID)
var count int
if err := q.QueryRow(query, args...).Scan(&count); err != nil {
return false, fmt.Errorf("校验客户端可见范围失败: %w", err)
}
return count == 1, nil
}
// ListClientsForUserPage 返回一页客户端和同一可见范围内的总数。
func ListClientsForUserPage(db *sql.DB, keyword, visibleUserID string, limit, offset int) ([]ClientWithAssignee, int, error) {
where, args := clientListFilter(keyword, visibleUserID)
+52
View File
@@ -0,0 +1,52 @@
package repository
import (
"database/sql"
"errors"
"fmt"
"cmautobuy/admin/model"
)
// GetSKUMapping 读取某个蝦皮 SKU 在指定 PDD 商品下的映射。
func GetSKUMapping(q Execer, shopeeSKUID, pddGoodsID string) (*model.SKUMapping, error) {
var m model.SKUMapping
var mappedBy sql.NullString
err := q.QueryRow(`
SELECT shopee_sku_id, pdd_goods_id, pdd_option_key, pdd_options,
goods_id, mapped_at, mapped_by
FROM sku_mappings
WHERE shopee_sku_id = ? AND pdd_goods_id = ?`,
shopeeSKUID, pddGoodsID).Scan(
&m.ShopeeSKUID, &m.PddGoodsID, &m.PddOptionKey, &m.PddOptions,
&m.GoodsID, &m.MappedAt, &mappedBy)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("查询规格映射失败: %w", err)
}
m.MappedBy = mappedBy.String
return &m, nil
}
// UpsertSKUMapping 保存可复用规格映射,唯一键包含当前 PDD 商品。
func UpsertSKUMapping(q Execer, m model.SKUMapping) error {
_, err := q.Exec(`
INSERT INTO sku_mappings
(shopee_sku_id, pdd_goods_id, pdd_option_key, pdd_options,
goods_id, mapped_at, mapped_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(shopee_sku_id, pdd_goods_id) DO UPDATE SET
pdd_option_key = excluded.pdd_option_key,
pdd_options = excluded.pdd_options,
goods_id = excluded.goods_id,
mapped_at = excluded.mapped_at,
mapped_by = excluded.mapped_by`,
m.ShopeeSKUID, m.PddGoodsID, m.PddOptionKey, m.PddOptions,
m.GoodsID, m.MappedAt, nullableText(m.MappedBy))
if err != nil {
return fmt.Errorf("保存规格映射失败: %w", err)
}
return nil
}
+26 -5
View File
@@ -320,7 +320,9 @@ func sybOrderFilterClause(filter SybOrderFilter) (string, []any) {
const sybOrderContextFrom = `
FROM syb_orders so
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
ON sm.shopee_sku_id = so.shopee_sku_id AND sm.pdd_goods_id = sp.pdd_goods_id`
// SybOrderContext 是顺运宝明细及其当前蝦皮/PDD 处理上下文。
// 处理阶段由 service 计算,Repository 只提供数据库事实。
@@ -331,6 +333,10 @@ type SybOrderContext struct {
PddGoodsURL string
PddCollectStatus string
PddCollectMsg string
PddSkusJSON string
MappingOptionKey string
MappingOptions string
HasActiveTask bool
}
func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
@@ -338,12 +344,15 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
var title, productSpec, shopeeGoodsID, shopeeSKUID, imageURL sql.NullString
var priceCent sql.NullInt64
var shopeeExists int
var pddGoodsID, pddGoodsURL, collectStatus, collectMsg sql.NullString
var pddGoodsID, pddGoodsURL, collectStatus, collectMsg, skusJSON sql.NullString
var mappingKey, mappingOptions sql.NullString
var hasActiveTask int
err := s.Scan(
&c.Order.SybID, &c.Order.OrderNo, &title, &productSpec, &shopeeGoodsID, &shopeeSKUID,
&c.Order.Quantity, &priceCent, &imageURL, &c.Order.SybData,
&c.Order.CreatedAt, &c.Order.UpdatedAt, &shopeeExists,
&pddGoodsID, &pddGoodsURL, &collectStatus, &collectMsg,
&pddGoodsID, &pddGoodsURL, &collectStatus, &collectMsg, &skusJSON,
&mappingKey, &mappingOptions, &hasActiveTask,
)
c.Order.Title = title.String
c.Order.ProductSpec = productSpec.String
@@ -356,6 +365,10 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
c.PddGoodsURL = pddGoodsURL.String
c.PddCollectStatus = collectStatus.String
c.PddCollectMsg = collectMsg.String
c.PddSkusJSON = skusJSON.String
c.MappingOptionKey = mappingKey.String
c.MappingOptions = mappingOptions.String
c.HasActiveTask = hasActiveTask != 0
return c, err
}
@@ -367,7 +380,11 @@ func ListSybOrderContexts(q Execer, filter SybOrderFilter, limit, offset int) ([
so.shopee_goods_id, so.shopee_sku_id, so.quantity,
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,
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,
pp.skus_json, sm.pdd_option_key, sm.pdd_options,
EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase'
AND t.syb_id = so.syb_id
AND t.status IN ('pending', 'assigned', 'claimed'))` +
sybOrderContextFrom + where + `
ORDER BY so.updated_at DESC, so.syb_id DESC LIMIT ? OFFSET ?`
args = append(args, limit, offset)
@@ -394,7 +411,11 @@ func GetSybOrderContext(q Execer, sybID string) (*SybOrderContext, error) {
so.shopee_goods_id, so.shopee_sku_id, so.quantity,
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,
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,
pp.skus_json, sm.pdd_option_key, sm.pdd_options,
EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase'
AND t.syb_id = so.syb_id
AND t.status IN ('pending', 'assigned', 'claimed'))`+
sybOrderContextFrom+` WHERE so.syb_id = ?`, sybID))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
+37
View File
@@ -445,3 +445,40 @@ func InsertCollectTask(q Execer, taskID, goodsID, goodsURL string) error {
}
return nil
}
// HasActivePurchaseTask 判断顺运宝明细是否已有尚未结束的采购任务。
func HasActivePurchaseTask(q Execer, sybID string) (bool, error) {
var count int
err := q.QueryRow(`
SELECT COUNT(*) FROM tasks
WHERE task_type = 'purchase' AND syb_id = ?
AND status IN ('pending', 'assigned', 'claimed')`, sybID).Scan(&count)
if err != nil {
return false, fmt.Errorf("检查顺运宝明细 %s 的进行中采购任务失败: %w", sybID, err)
}
return count > 0, nil
}
// InsertPurchaseTask 插入一条已分配、等待指定客户端领取的采购任务。
func InsertPurchaseTask(q Execer, task model.Task) error {
if task.TaskID == "" || task.AssignedClient == "" || task.SybID == "" ||
task.PddGoodsURL == "" || task.PddGoodsID == "" || task.PddOptions == "" ||
task.Quantity <= 0 || task.MaxPriceCent <= 0 {
return fmt.Errorf("采购任务缺少客户端、商品、规格、数量或人民币价格上限")
}
now := model.NowISO()
_, err := q.Exec(`
INSERT INTO tasks
(task_id, task_type, status, assigned_client,
syb_id, order_no, goods_id, shopee_sku_id,
pdd_goods_url, pdd_goods_id, pdd_options,
quantity, max_price_cent, created_at, updated_at)
VALUES (?, 'purchase', 'assigned', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.TaskID, task.AssignedClient, task.SybID, task.OrderNo,
task.GoodsID, task.ShopeeSKUID, task.PddGoodsURL, task.PddGoodsID,
task.PddOptions, task.Quantity, task.MaxPriceCent, now, now)
if err != nil {
return fmt.Errorf("创建顺运宝明细 %s 的采购任务失败: %w", task.SybID, err)
}
return nil
}
+293
View File
@@ -0,0 +1,293 @@
package service
import (
"database/sql"
"fmt"
"math"
"strings"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
// PddOptionChoice 是采集结果中的一个可购买规格组合。
type PddOptionChoice struct {
Key string
OptionsJSON string
Label string
PriceText string
PriceCent int64
HasPrice bool
Selected bool
}
func pddOptionChoices(raw string) ([]PddOptionChoice, []string, error) {
collected, err := parseCollected(raw)
if err != nil {
return nil, nil, err
}
keys, names := dimensionOrder(collected)
choices := make([]PddOptionChoice, 0, len(collected.SKUs))
seen := map[string]bool{}
for _, sku := range collected.SKUs {
if !sku.Available || len(sku.Options) == 0 {
continue
}
key, err := OptionKey(sku.Options)
if err != nil {
return nil, nil, err
}
if seen[key] {
continue
}
seen[key] = true
parts := make([]string, 0, len(keys))
for i, dimensionKey := range keys {
name := dimensionKey
if i < len(names) && names[i] != "" {
name = names[i]
}
parts = append(parts, name+":"+sku.Options[dimensionKey])
}
choice := PddOptionChoice{
Key: key, OptionsJSON: key, Label: strings.Join(parts, " / "),
PriceText: formatPriceCent(sku.PriceCent),
}
if sku.PriceCent != nil && *sku.PriceCent > 0 {
choice.PriceCent, choice.HasPrice = *sku.PriceCent, true
}
choices = append(choices, choice)
}
return choices, names, nil
}
func findPddChoice(raw, key string) (*PddOptionChoice, error) {
choices, _, err := pddOptionChoices(raw)
if err != nil {
return nil, err
}
for _, choice := range choices {
if choice.Key == key {
copy := choice
return &copy, nil
}
}
return nil, nil
}
// SaveSybMapping 保存顺运宝明细对应蝦皮 SKU 到当前 PDD 商品的可复用映射。
func SaveSybMapping(db *sql.DB, sybID, optionKey, operator 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("顺运宝明细不存在")
}
if context.Order.ShopeeSKUID == "" {
return fmt.Errorf("请先确认蝦皮规格")
}
if context.PddGoodsID == "" || context.PddCollectStatus != string(model.CollectCollected) ||
strings.TrimSpace(context.PddSkusJSON) == "" {
return fmt.Errorf("当前 PDD 商品尚未完成采集")
}
choice, err := findPddChoice(context.PddSkusJSON, strings.TrimSpace(optionKey))
if err != nil {
return fmt.Errorf("读取 PDD 规格失败: %w", err)
}
if choice == nil {
return fmt.Errorf("所选 PDD 规格已不存在或不可购买,请刷新后重试")
}
if err := repository.UpsertSKUMapping(tx, model.SKUMapping{
ShopeeSKUID: context.Order.ShopeeSKUID, PddGoodsID: context.PddGoodsID,
PddOptionKey: choice.Key, PddOptions: choice.OptionsJSON,
GoodsID: context.Order.ShopeeGoodsID, MappedAt: model.NowISO(),
MappedBy: strings.TrimSpace(operator),
}); err != nil {
return err
}
return tx.Commit()
}
func mappingIsValid(context repository.SybOrderContext) bool {
if context.MappingOptionKey == "" || context.PddSkusJSON == "" {
return false
}
choice, err := findPddChoice(context.PddSkusJSON, context.MappingOptionKey)
return err == nil && choice != nil
}
// PurchaseTaskRequest 是一条采购任务的人工确认输入。价格单位固定为人民币分。
type PurchaseTaskRequest struct {
SybID string
MaxPriceCent int64
}
// PurchaseTaskResult 同时返回已创建数量和每条无法创建的原因。
type PurchaseTaskResult struct {
Created int
Failures []TaskCreateError
}
// CreatePurchaseTasks 校验并创建采购任务。业务校验失败按明细返回,数据库错误整体回滚。
func CreatePurchaseTasks(db *sql.DB, actor *model.User, requests []PurchaseTaskRequest, clientID string) (PurchaseTaskResult, error) {
var result PurchaseTaskResult
visibleUserID, err := visibleClientUserID(actor)
if err != nil {
return result, err
}
clientID = strings.TrimSpace(clientID)
if clientID == "" {
return result, fmt.Errorf("请选择执行采购任务的客户端")
}
tx, err := db.Begin()
if err != nil {
return result, fmt.Errorf("开始创建采购任务事务失败: %w", err)
}
defer tx.Rollback()
visible, err := repository.ClientVisibleToUser(tx, clientID, visibleUserID)
if err != nil {
return result, err
}
if !visible {
return result, fmt.Errorf("所选客户端不存在或不在当前账号可见范围")
}
seen := map[string]bool{}
for _, request := range requests {
request.SybID = strings.TrimSpace(request.SybID)
if request.SybID == "" || seen[request.SybID] {
if request.SybID != "" {
result.Failures = append(result.Failures, TaskCreateError{SybID: request.SybID, Reason: "重复选择"})
}
continue
}
seen[request.SybID] = true
reason, context, optionsJSON, err := validatePurchaseRequest(tx, request)
if err != nil {
return PurchaseTaskResult{}, err
}
if reason != "" {
result.Failures = append(result.Failures, TaskCreateError{SybID: request.SybID, Reason: reason})
continue
}
if err := repository.InsertPurchaseTask(tx, model.Task{
TaskID: newPurchaseTaskID(), AssignedClient: clientID,
SybID: context.Order.SybID, OrderNo: context.Order.OrderNo,
GoodsID: context.Order.ShopeeGoodsID, ShopeeSKUID: context.Order.ShopeeSKUID,
PddGoodsURL: context.PddGoodsURL, PddGoodsID: context.PddGoodsID,
PddOptions: optionsJSON, Quantity: context.Order.Quantity,
MaxPriceCent: request.MaxPriceCent,
}); err != nil {
return PurchaseTaskResult{}, err
}
result.Created++
}
if err := tx.Commit(); err != nil {
return PurchaseTaskResult{}, fmt.Errorf("提交采购任务失败: %w", err)
}
return result, nil
}
func validatePurchaseRequest(q repository.Execer, request PurchaseTaskRequest) (string, *repository.SybOrderContext, string, error) {
context, err := repository.GetSybOrderContext(q, request.SybID)
if err != nil {
return "", nil, "", err
}
if context == nil {
return "顺运宝明细不存在", nil, "", nil
}
if context.Order.ShopeeSKUID == "" {
return "蝦皮规格未确认", context, "", nil
}
if context.PddGoodsID == "" || context.PddGoodsURL == "" {
return "未关联 PDD 商品", context, "", nil
}
if context.PddCollectStatus != string(model.CollectCollected) || context.PddSkusJSON == "" {
return "PDD 商品尚未采集完成", context, "", nil
}
if context.MappingOptionKey == "" {
return "PDD 规格尚未匹配", context, "", nil
}
choice, err := findPddChoice(context.PddSkusJSON, context.MappingOptionKey)
if err != nil {
return "", nil, "", err
}
if choice == nil {
return "已保存的 PDD 规格已失效,请重新匹配", context, "", nil
}
if context.Order.Quantity <= 0 {
return "采购数量必须大于 0", context, "", nil
}
if request.MaxPriceCent <= 0 {
return "人民币价格上限必须大于 0", context, "", nil
}
active, err := repository.HasActivePurchaseTask(q, request.SybID)
if err != nil {
return "", nil, "", err
}
if active {
return "已有未结束的采购任务", context, "", nil
}
return "", context, choice.OptionsJSON, nil
}
func newPurchaseTaskID() string {
id := newID()
if len(id) > 16 {
id = id[:16]
}
return "PUR-" + id
}
// parsePriceYuanToCent 把网页输入的人民币元精确转成分,不使用浮点数。
func ParsePriceYuanToCent(raw string) (int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, fmt.Errorf("价格上限不能为空")
}
var whole, fraction string
parts := strings.Split(raw, ".")
if len(parts) > 2 {
return 0, fmt.Errorf("价格格式不正确")
}
whole = parts[0]
if len(parts) == 2 {
fraction = parts[1]
}
if whole == "" || len(fraction) > 2 {
return 0, fmt.Errorf("价格最多保留两位小数")
}
for _, value := range []string{whole, fraction} {
for _, r := range value {
if r < '0' || r > '9' {
return 0, fmt.Errorf("价格只能填写数字")
}
}
}
if len(fraction) == 0 {
fraction = "00"
} else if len(fraction) == 1 {
fraction += "0"
}
var yuan, cent int64
if _, err := fmt.Sscan(whole, &yuan); err != nil {
return 0, fmt.Errorf("价格格式不正确")
}
if _, err := fmt.Sscan(fraction, &cent); err != nil {
return 0, fmt.Errorf("价格格式不正确")
}
if yuan > (math.MaxInt64-cent)/100 {
return 0, fmt.Errorf("价格上限过大")
}
total := yuan*100 + cent
if total <= 0 {
return 0, fmt.Errorf("价格上限必须大于 0")
}
return total, nil
}
+192
View File
@@ -0,0 +1,192 @@
package service
import (
"database/sql"
"testing"
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
const collectedThreeDimensions = `{
"goods_id":"737116531267",
"price_granularity":"sku",
"dimensions":[{"key":"color","name":"颜色"},{"key":"size","name":"尺码"},{"key":"style","name":"款式"}],
"skus":[
{"options":{"style":"常规","size":"M码","color":"黑色"},"price_cent":3990,"available":true},
{"options":{"style":"加绒","size":"L码","color":"白色"},"price_cent":4590,"available":true},
{"options":{"style":"下架","size":"S码","color":"红色"},"price_cent":2990,"available":false}
]
}`
func seedPurchasableWorkflow(t *testing.T, db *sql.DB, sybID string) string {
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.PriceTwdCent = 999900
if _, err := repository.UpsertSybOrder(db, order); err != nil {
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 {
t.Fatal(err)
}
if err := repository.SetCollectResult(db, "737116531267", "PDD 上衣", "店铺", collectedThreeDimensions); err != nil {
t.Fatal(err)
}
key, _ := OptionKey(map[string]string{"color": "黑色", "size": "M码", "style": "常规"})
return key
}
func TestSybMapping_动态维度保存并复用(t *testing.T) {
db := newTestDB(t)
key := seedPurchasableWorkflow(t, db, "SYB-1")
detail, err := GetSybProcessingDetail(db, "SYB-1")
if err != nil {
t.Fatal(err)
}
if len(detail.PddDimensionNames) != 3 || detail.PddDimensionNames[2] != "款式" || len(detail.PddChoices) != 2 {
t.Fatalf("动态维度/可用组合不对: names=%v choices=%+v", detail.PddDimensionNames, detail.PddChoices)
}
if err := SaveSybMapping(db, "SYB-1", key, "USR-1"); err != nil {
t.Fatal(err)
}
detail, _ = GetSybProcessingDetail(db, "SYB-1")
if !detail.MappingValid {
t.Fatal("保存后相同蝦皮 SKU + 当前 PDD 商品应自动复用")
}
list, _ := ListSybOrdersView(db, "", SybStagePurchaseReady, 1)
if list.Total != 1 || !list.Rows[0].CanPurchase || list.Rows[0].DefaultMaxPrice != "39.90" {
t.Fatalf("可采购阶段不对: %+v", list)
}
}
func TestSybMapping_换品和选项消失都会失效(t *testing.T) {
t.Run("换 PDD 商品隔离旧映射", func(t *testing.T) {
db := newTestDB(t)
key := seedPurchasableWorkflow(t, db, "SYB-1")
SaveSybMapping(db, "SYB-1", key, "USR-1")
if _, err := AssociateShopeePdd(db, "SP-1", pddURLB, true); err != nil {
t.Fatal(err)
}
repository.SetCollectResult(db, "937122477375", "另一个商品", "店铺", stringsReplaceGoodsID(collectedThreeDimensions))
detail, _ := GetSybProcessingDetail(db, "SYB-1")
if detail.MappingValid {
t.Fatal("更换 PDD 商品后不得复用旧映射")
}
})
t.Run("最新采集结果删除原选项", func(t *testing.T) {
db := newTestDB(t)
key := seedPurchasableWorkflow(t, db, "SYB-1")
SaveSybMapping(db, "SYB-1", key, "USR-1")
changed := `{"goods_id":"737116531267","dimensions":[{"key":"color","name":"颜色"}],"skus":[{"options":{"color":"白色"},"price_cent":1000,"available":true}]}`
repository.SetCollectResult(db, "737116531267", "PDD 上衣", "店铺", changed)
detail, _ := GetSybProcessingDetail(db, "SYB-1")
if detail.MappingValid || detail.Stage != SybStageMappingPending {
t.Fatalf("失效映射应回到待匹配: valid=%t stage=%s", detail.MappingValid, detail.Stage)
}
})
}
func stringsReplaceGoodsID(raw string) string {
return `{"goods_id":"937122477375","price_granularity":"sku","dimensions":[{"key":"color","name":"颜色"},{"key":"size","name":"尺码"},{"key":"style","name":"款式"}],"skus":[{"options":{"style":"常规","size":"M码","color":"黑色"},"price_cent":3990,"available":true}]}`
}
func TestCreatePurchaseTasks_安全字段和重复保护(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)
}
admin, _, _ := prepareClientAssignmentUsers(t, db)
if err := RegisterClient(db, model.Client{ClientID: "CLIENT-1", Name: "采购机"}, true); err != nil {
t.Fatal(err)
}
result, err := CreatePurchaseTasks(db, admin,
[]PurchaseTaskRequest{{SybID: "SYB-1", MaxPriceCent: 4200}}, "CLIENT-1")
if err != nil || result.Created != 1 || len(result.Failures) != 0 {
t.Fatalf("创建结果=%+v err=%v", result, err)
}
var task model.Task
var status string
err = db.QueryRow(`SELECT status, assigned_client, syb_id, order_no, goods_id,
shopee_sku_id, pdd_goods_url, pdd_goods_id, pdd_options, quantity, max_price_cent
FROM tasks WHERE task_type='purchase' AND syb_id='SYB-1'`).Scan(
&status, &task.AssignedClient, &task.SybID, &task.OrderNo, &task.GoodsID,
&task.ShopeeSKUID, &task.PddGoodsURL, &task.PddGoodsID, &task.PddOptions,
&task.Quantity, &task.MaxPriceCent)
if err != nil {
t.Fatal(err)
}
if status != "assigned" || task.MaxPriceCent != 4200 || task.MaxPriceCent == 999900 ||
task.Quantity != 1 || task.PddOptions != key || task.AssignedClient != "CLIENT-1" {
t.Fatalf("采购任务字段不安全或不完整: status=%s task=%+v", status, task)
}
claimed, err := repository.ClaimNextTask(db, "CLIENT-1", []string{"purchase"})
if err != nil || claimed == nil || claimed.PddOptions != key ||
claimed.Quantity != 1 || claimed.MaxPriceCent != 4200 {
t.Fatalf("现有 Client 领取契约读不到完整采购参数: task=%+v err=%v", claimed, err)
}
second, err := CreatePurchaseTasks(db, admin,
[]PurchaseTaskRequest{{SybID: "SYB-1", MaxPriceCent: 4300}}, "CLIENT-1")
if err != nil || second.Created != 0 || len(second.Failures) != 1 {
t.Fatalf("重复提交应逐条拒绝: result=%+v err=%v", second, err)
}
}
func TestCreatePurchaseTasks_客户端权限和逐条失败(t *testing.T) {
db := newTestDB(t)
key := seedPurchasableWorkflow(t, db, "SYB-1")
SaveSybMapping(db, "SYB-1", key, "USR-1")
admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db)
RegisterClient(db, model.Client{ClientID: "CLIENT-A"}, true)
AssignClient(db, admin, "CLIENT-A", buyerA.UserID, time.Now())
if _, err := CreatePurchaseTasks(db, buyerB,
[]PurchaseTaskRequest{{SybID: "SYB-1", MaxPriceCent: 4200}}, "CLIENT-A"); err == nil {
t.Fatal("采购员不得使用其他采购员的客户端")
}
result, err := CreatePurchaseTasks(db, buyerA,
[]PurchaseTaskRequest{{SybID: "SYB-1", MaxPriceCent: 0}, {SybID: "NOT-FOUND", MaxPriceCent: 100}}, "CLIENT-A")
if err != nil || result.Created != 0 || len(result.Failures) != 2 {
t.Fatalf("业务失败应逐条返回且不创建: result=%+v err=%v", result, err)
}
}
func TestCreatePurchaseTasks_部分业务失败不影响有效明细(t *testing.T) {
db := newTestDB(t)
key := seedPurchasableWorkflow(t, db, "SYB-1")
SaveSybMapping(db, "SYB-1", key, "USR-1")
admin, _, _ := prepareClientAssignmentUsers(t, db)
RegisterClient(db, model.Client{ClientID: "CLIENT-1"}, true)
result, err := CreatePurchaseTasks(db, admin, []PurchaseTaskRequest{
{SybID: "NOT-FOUND", MaxPriceCent: 100},
{SybID: "SYB-1", MaxPriceCent: 4200},
}, "CLIENT-1")
if err != nil || result.Created != 1 || len(result.Failures) != 1 || result.Failures[0].SybID != "NOT-FOUND" {
t.Fatalf("部分失败边界不对: result=%+v err=%v", result, err)
}
}
func TestParsePriceYuanToCent_精确转换(t *testing.T) {
for raw, want := range map[string]int64{"39.90": 3990, "1": 100, "0.01": 1, "12.3": 1230} {
got, err := ParsePriceYuanToCent(raw)
if err != nil || got != want {
t.Fatalf("%s => %d, %v; want %d", raw, got, err, want)
}
}
for _, raw := range []string{"", "0", "-1", "1.234", "abc"} {
if _, err := ParsePriceYuanToCent(raw); err == nil {
t.Fatalf("应拒绝 %q", raw)
}
}
}
-28
View File
@@ -40,17 +40,6 @@ func CreateCollectTasks(db *sql.DB, goodsIDs []string, clientID string) (created
return 0, 0, ErrNotImplemented
}
// ---------- 规格匹配 ----------
// SaveMapping 保存「蝦皮规格 = PDD 规格」的对应关系。
//
// 存的是**可复用的映射**(sku_mappings 表),不是某一张订单的临时数据。
// 下次遇到同一个蝦皮 SKU 自动带出,操作员只需确认。
func SaveMapping(db *sql.DB, shopeeSKUID, goodsID, pddOptionsJSON, operator string) error {
// TODO(骨架): upsert sku_mappings
return ErrNotImplemented
}
// ---------- 采购任务 ----------
// TaskCreateError 说明某一条为什么建不了任务。
@@ -60,23 +49,6 @@ type TaskCreateError struct {
Reason string // 例如「该商品未填写 PDD 链接」
}
// CreatePurchaseTasks 由货运单创建采购任务。
//
// 六条校验缺一不可(docs/admin/01-requirements.md §5):
// 1. 已填 PDD 链接
// 2. 已采集成功(pdd_data 非空)
// 3. 已有 SKU 映射
// 4. 数量 > 0
// 5. **价格上限已填且 > 0**——默认从 pdd_data 里该 SKU 的价格带出,
// 操作员可改但不允许为空。没有它 Client 会拒绝执行。
// 6. 已选择分配的客户端
//
// 另外 pdd_goods_url 必须写进任务,Client 那边是 NOT NULL。
func CreatePurchaseTasks(db *sql.DB, sybIDs []string, clientID string) (created int, failures []TaskCreateError, err error) {
// TODO(骨架)
return 0, nil, ErrNotImplemented
}
// ---------- 客户端 ----------
// RegisterClient 登记或更新一台客户端。
+153 -66
View File
@@ -916,31 +916,36 @@ func EnsureSybSession(db *sql.DB, client *syb.Client, username string, now time.
// SybOrderView 是列表页一行要显示的全部内容,已经格式化成字符串,
// 模板里不做判断和格式化,和其余四个模块的做法一致。
type SybOrderView struct {
SybID string
OrderNo string
Title string
ProductSpec string
ShopeeGoodsID string
ShopeeSKUID string
Quantity int
PriceText string // "NT$239.00",和人民币价格一眼分得清
ImageURL string
Stage string
StageText string
StageHelp string
ActionText string
NeedsAttention bool
UpdatedAt string
SybID string
OrderNo string
Title string
ProductSpec string
ShopeeGoodsID string
ShopeeSKUID string
Quantity int
PriceText string // "NT$239.00",和人民币价格一眼分得清
ImageURL string
Stage string
StageText string
StageHelp string
ActionText string
NeedsAttention bool
CanPurchase bool
DefaultMaxPrice string
UpdatedAt string
}
const (
SybStageMissingShopee = "missing_shopee"
SybStageSKUPending = "sku_pending"
SybStagePddMissing = "pdd_missing"
SybStagePddPending = "pdd_pending"
SybStagePddCollecting = "pdd_collecting"
SybStagePddFailed = "pdd_failed"
SybStageMappingPending = "pdd_collected"
SybStageMissingShopee = "missing_shopee"
SybStageSKUPending = "sku_pending"
SybStagePddMissing = "pdd_missing"
SybStagePddPending = "pdd_pending"
SybStagePddCollecting = "pdd_collecting"
SybStagePddFailed = "pdd_failed"
SybStageMappingPending = "mapping_pending"
SybStagePurchaseReady = "purchase_ready"
SybStageTaskCreated = "task_created"
SybStagePurchaseBlocked = "purchase_blocked"
)
// SybStageOption 是顺运宝处理阶段筛选项。
@@ -959,6 +964,9 @@ func SybStageOptions() []SybStageOption {
{Value: SybStagePddCollecting, Text: "PDD 采集中"},
{Value: SybStagePddFailed, Text: "PDD 采集失败"},
{Value: SybStageMappingPending, Text: "规格待匹配"},
{Value: SybStagePurchaseReady, Text: "可创建采购任务"},
{Value: SybStageTaskCreated, Text: "已创建采购任务"},
{Value: SybStagePurchaseBlocked, Text: "采购数据异常"},
}
}
@@ -998,8 +1006,14 @@ func sybStageFor(c repository.SybOrderContext) (stage, text, help, action string
help += " 原因:" + c.PddCollectMsg
}
return SybStagePddFailed, "PDD 采集失败", help, "重新采集"
default:
case c.HasActiveTask:
return SybStageTaskCreated, "已创建采购任务", "已有未结束的采购任务,请到采集采购页面查看。", "查看任务"
case !mappingIsValid(c):
return SybStageMappingPending, "规格待匹配", "PDD 数据已采集,下一步匹配采购规格。", "匹配规格"
case c.Order.Quantity <= 0:
return SybStagePurchaseBlocked, "采购数据异常", "顺运宝采购数量不是正数,不能创建采购任务。", "核对数据"
default:
return SybStagePurchaseReady, "可创建采购任务", "规格映射有效,可以勾选并创建采购任务。", "核对规格"
}
}
@@ -1021,6 +1035,10 @@ type SybListResult struct {
// 顺运宝规格对应的蝦皮 SKU,不代表已经完成 PDD 规格映射。
func ListSybOrdersView(db *sql.DB, keyword, stage string, page int) (*SybListResult, error) {
stage = ParseSybStage(stage)
if stage == SybStageMappingPending || stage == SybStagePurchaseReady ||
stage == SybStageTaskCreated || stage == SybStagePurchaseBlocked {
return listAdvancedSybStage(db, keyword, stage, page)
}
filter := repository.SybOrderFilter{Keyword: keyword, Stage: stage}
total, err := repository.CountSybOrders(db, filter)
if err != nil {
@@ -1050,36 +1068,87 @@ func ListSybOrdersView(db *sql.DB, keyword, stage string, page int) (*SybListRes
TotalPages: totalPages,
}
for _, context := range rows {
o := context.Order
v := SybOrderView{
SybID: o.SybID,
OrderNo: o.OrderNo,
Title: o.Title,
ProductSpec: o.ProductSpec,
ShopeeGoodsID: o.ShopeeGoodsID,
ShopeeSKUID: o.ShopeeSKUID,
Quantity: o.Quantity,
ImageURL: o.ImageURL,
UpdatedAt: formatLocalTime(o.UpdatedAt),
}
v.Stage, v.StageText, v.StageHelp, v.ActionText = sybStageFor(context)
v.NeedsAttention = v.Stage != SybStagePddCollecting
if o.PriceTwdCent > 0 {
v.PriceText = fmt.Sprintf("NT$%.2f", float64(o.PriceTwdCent)/100)
} else {
v.PriceText = placeholder
}
if o.Title == "" {
v.Title = placeholder
}
if o.ProductSpec == "" {
v.ProductSpec = placeholder
}
result.Rows = append(result.Rows, v)
result.Rows = append(result.Rows, sybOrderViewFor(context))
}
return result, nil
}
// listAdvancedSybStage 在 Service 解析最新 skus_json 后筛选映射相关阶段。
// 这三个阶段不能只靠 SQL 判断:同一个 option key 是否仍存在,需要走唯一的
// OptionKey 规范化逻辑。当前同步量是百到千级,先保证采购判断正确;普通列表
// 和其余阶段仍在数据库分页。
func listAdvancedSybStage(db *sql.DB, keyword, stage string, page int) (*SybListResult, error) {
contexts, err := repository.ListSybOrderContexts(db,
repository.SybOrderFilter{Keyword: keyword}, -1, 0)
if err != nil {
return nil, err
}
filtered := make([]repository.SybOrderContext, 0)
for _, context := range contexts {
value, _, _, _ := sybStageFor(context)
if value == stage {
filtered = append(filtered, context)
}
}
total := len(filtered)
totalPages := TotalPages(total)
page = ClampPage(page, totalPages)
start := (page - 1) * PageSize
if start > total {
start = total
}
end := start + PageSize
if end > total {
end = total
}
result := &SybListResult{Total: total, Stage: stage, Page: page, TotalPages: totalPages,
IsFiltered: true, Rows: make([]SybOrderView, 0, end-start)}
hasAny, err := repository.CountSybOrdersTotal(db)
if err != nil {
return nil, err
}
result.HasAny = hasAny > 0
for _, context := range filtered[start:end] {
result.Rows = append(result.Rows, sybOrderViewFor(context))
}
return result, nil
}
func sybOrderViewFor(context repository.SybOrderContext) SybOrderView {
o := context.Order
v := SybOrderView{SybID: o.SybID, OrderNo: o.OrderNo, Title: o.Title,
ProductSpec: o.ProductSpec, ShopeeGoodsID: o.ShopeeGoodsID,
ShopeeSKUID: o.ShopeeSKUID, Quantity: o.Quantity, ImageURL: o.ImageURL,
UpdatedAt: formatLocalTime(o.UpdatedAt)}
v.Stage, v.StageText, v.StageHelp, v.ActionText = sybStageFor(context)
v.CanPurchase = v.Stage == SybStagePurchaseReady
v.DefaultMaxPrice = defaultMaxPrice(context)
v.NeedsAttention = v.Stage != SybStagePddCollecting && v.Stage != SybStageTaskCreated
if o.PriceTwdCent > 0 {
v.PriceText = fmt.Sprintf("NT$%.2f", float64(o.PriceTwdCent)/100)
} else {
v.PriceText = placeholder
}
if v.Title == "" {
v.Title = placeholder
}
if v.ProductSpec == "" {
v.ProductSpec = placeholder
}
return v
}
func defaultMaxPrice(context repository.SybOrderContext) string {
if !mappingIsValid(context) {
return ""
}
choice, err := findPddChoice(context.PddSkusJSON, context.MappingOptionKey)
if err != nil || choice == nil || !choice.HasPrice {
return ""
}
return fmt.Sprintf("%d.%02d", choice.PriceCent/100, choice.PriceCent%100)
}
// AutoResolveSybShopeeSKU 只接受确定性唯一候选:先比规格原文,再比解析后的
// 颜色和尺码。零候选或多候选都不写库,交给采购员确认。
func AutoResolveSybShopeeSKU(q repository.Execer, order model.SybOrder) (string, error) {
@@ -1165,23 +1234,27 @@ type SybSKUCandidateView struct {
// SybProcessingDetail 是顺运宝“下一步”弹窗第一阶段需要的上下文。
type SybProcessingDetail struct {
SybID string
OrderNo string
Title string
ProductSpec string
ShopeeGoodsID string
ShopeeSKUID string
Quantity int
Stage string
StageText string
StageHelp string
Candidates []SybSKUCandidateView
ShopeeExists bool
PddGoodsID string
PddURL string
CollectStatus string
CollectMsg string
CanCollect bool
SybID string
OrderNo string
Title string
ProductSpec string
ShopeeGoodsID string
ShopeeSKUID string
Quantity int
Stage string
StageText string
StageHelp string
Candidates []SybSKUCandidateView
ShopeeExists bool
PddGoodsID string
PddURL string
CollectStatus string
CollectMsg string
CanCollect bool
PddDimensionNames []string
PddChoices []PddOptionChoice
MappingValid bool
HasActiveTask bool
}
func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, error) {
@@ -1200,6 +1273,20 @@ func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, err
d.CanCollect = context.PddGoodsID != "" &&
(context.PddCollectStatus == string(model.CollectPending) ||
context.PddCollectStatus == string(model.CollectFailed))
d.HasActiveTask = context.HasActiveTask
if context.PddCollectStatus == string(model.CollectCollected) && context.PddSkusJSON != "" {
choices, names, parseErr := pddOptionChoices(context.PddSkusJSON)
if parseErr == nil {
d.PddDimensionNames = names
d.PddChoices = choices
for i := range d.PddChoices {
d.PddChoices[i].Selected = d.PddChoices[i].Key == context.MappingOptionKey
if d.PddChoices[i].Selected {
d.MappingValid = true
}
}
}
}
d.Stage, d.StageText, d.StageHelp, _ = sybStageFor(*context)
if context.ShopeeExists {
skus, err := repository.ListShopeeSKUsByGoodsID(db, o.ShopeeGoodsID)
+13
View File
@@ -264,6 +264,19 @@ tr.empty small { color: #aaa; }
.modal-body { padding: 16px; }
.modal-body h3 { font-size: 14px; margin: 20px 0 8px; }
.form-stack { display: grid; gap: 16px; }
.purchase-confirm-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
align-items: center;
gap: 16px;
padding: 12px;
border: 1px solid #d9dde3;
border-radius: 6px;
}
.purchase-confirm-row label { display: grid; gap: 6px; }
@media (max-width: 760px) {
.purchase-confirm-row { grid-template-columns: 1fr; }
}
.modal-foot {
display: flex;
align-items: center;
+28 -2
View File
@@ -19,7 +19,7 @@
if (!table) return;
function rowBoxes() {
return table.querySelectorAll("tbody input[type=checkbox]");
return table.querySelectorAll("tbody input[type=checkbox]:not(:disabled)");
}
master.addEventListener("change", function () {
@@ -42,7 +42,7 @@
/* ── 选中数量 ────────────────────────────── */
function checkedCount() {
return document.querySelectorAll(
"tbody input[type=checkbox]:checked"
"tbody input[type=checkbox]:checked:not(:disabled)"
).length;
}
@@ -134,6 +134,31 @@
});
}
/* 采购确认弹窗只负责把服务端已经渲染的本页行按勾选结果显示出来。
规格有效性、价格和客户端权限仍由服务端重新校验。 */
function setupPurchaseModal() {
var openButton = document.querySelector("[data-purchase-open]");
var form = document.querySelector("[data-purchase-form]");
if (!openButton || !form) return;
openButton.addEventListener("click", function () {
var selected = {};
document.querySelectorAll("tbody input[name=ids]:checked").forEach(function (box) {
selected[box.value] = true;
});
var shown = 0;
form.querySelectorAll("[data-purchase-row]").forEach(function (row) {
var enabled = !!selected[row.getAttribute("data-purchase-row")];
row.hidden = !enabled;
row.querySelectorAll("input").forEach(function (input) {
input.disabled = !enabled;
});
if (enabled) shown += 1;
});
var empty = form.querySelector("[data-purchase-empty]");
if (empty) empty.hidden = shown > 0;
});
}
/* ── 弹窗 ──────────────────────────────────
弹窗**内容由服务端渲染**,这里只负责显示、隐藏和把内容取回来。
不要在这里拼业务数据——价格格式、规格顺序都是业务规则,
@@ -349,6 +374,7 @@
setupConfirmSubmit();
setupUserResetModal();
setupClientAssignmentModal();
setupPurchaseModal();
setupModals();
setupRowDetail();
setupCaptchaRefresh();
+28
View File
@@ -76,6 +76,34 @@
<span class="hint">{{if eq .CollectStatus "collecting"}}采集任务正在执行,请稍后刷新。{{else}}PDD 数据已采集,下一步匹配采购规格。{{end}}</span>
{{end}}
</form>
{{if eq .CollectStatus "collected"}}
<h3>匹配采购规格</h3>
{{if .PddChoices}}
<form method="post" action="/syb/match" 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-pdd-option">蝦皮 {{.ProductSpec}} 对应的 PDD 规格</label>
<select id="syb-pdd-option" name="pdd_option_key" required>
<option value="">请选择,不做相似规格猜测</option>
{{range .PddChoices}}
<option value="{{.Key}}" {{if .Selected}}selected{{end}}>{{.Label}} · {{.PriceText}}</option>
{{end}}
</select>
<small>PDD 维度来自采集结果:{{range $i, $name := .PddDimensionNames}}{{if $i}} / {{end}}{{$name}}{{end}}。保存后相同蝦皮 SKU + 当前 PDD 商品会复用。</small>
</div>
{{if and (not .MappingValid) .PddChoices}}<p class="missing">尚无有效映射,或原映射已不在最新采集结果中,请重新选择。</p>{{end}}
<div><button type="submit" class="primary">保存规格映射</button></div>
</form>
{{else}}
<p class="missing">最新采集结果里没有可购买的规格组合,请核对 PDD 商品或重新采集。</p>
{{end}}
{{if .HasActiveTask}}<p class="hint">这条明细已有未结束采购任务,请到“采集采购”页面查看,不要重复创建。</p>{{end}}
{{end}}
{{end}}
{{end}}
</div>
+43 -8
View File
@@ -21,13 +21,7 @@
<button type="button" data-modal-open="sync-history-modal">同步记录</button>
<form class="inline" method="post" action="/syb/create-task">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="order_no" value="{{.Keyword}}">
<input type="hidden" name="stage" value="{{.StageFilter}}">
<input type="hidden" name="page" value="{{.CurrentPage}}">
<button type="submit" data-need-checked>创建采购任务</button>
</form>
<button type="button" data-need-checked data-modal-open="purchase-modal" data-purchase-open>创建采购任务</button>
<form class="inline grow" method="get" action="/syb">
<label for="stage">处理阶段</label>
@@ -110,7 +104,7 @@
{{range .Rows}}
<tr data-detail-id="{{.SybID}}" {{if .NeedsAttention}}class="row-warn"{{end}}>
<td class="col-check">
<input type="checkbox" value="{{.SybID}}" name="ids"
<input type="checkbox" value="{{.SybID}}" name="ids" {{if not .CanPurchase}}disabled{{end}}
aria-label="选择货运单明细 {{.SybID}}">
</td>
<td>{{.SybID}}</td>
@@ -145,6 +139,47 @@
</table>
</div>
<div class="modal-backdrop" id="purchase-modal" hidden>
<div class="modal modal-wide" role="dialog" aria-modal="true" aria-labelledby="purchase-modal-title">
<div class="modal-head">
<h2 id="purchase-modal-title">确认创建采购任务</h2>
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
</div>
<form method="post" action="/syb/create-task" data-purchase-form
data-confirm-submit="确认按下面的人民币价格上限创建采购任务?任务创建后将等待所选客户端领取。">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="order_no" value="{{.Keyword}}">
<input type="hidden" name="stage" value="{{.StageFilter}}">
<input type="hidden" name="page" value="{{.CurrentPage}}">
<div class="modal-body form-stack">
<div class="field">
<label for="purchase-client">执行客户端</label>
<select id="purchase-client" name="client_id" required autofocus>
<option value="">请选择当前账号可见的客户端</option>
{{range .AssignableClients}}<option value="{{.ClientID}}">{{.Name}}({{.ClientID}},{{.Status}})</option>{{end}}
</select>
{{if not .AssignableClients}}<small class="missing">当前账号没有可用客户端,请先让管理员完成客户端绑定。</small>{{end}}
</div>
<p class="hint">价格上限单位是人民币元,默认取已映射 PDD 规格的采集价;不会使用顺运宝的台币售价。</p>
{{range .Rows}}
<div class="purchase-confirm-row" data-purchase-row="{{.SybID}}" hidden>
<input type="hidden" name="ids" value="{{.SybID}}" disabled>
<div><strong>{{.OrderNo}}</strong> · {{.Title}} · {{.ProductSpec}} × {{.Quantity}}</div>
<label>人民币价格上限(元)
<input type="number" name="max_price_yuan" min="0.01" step="0.01" value="{{.DefaultMaxPrice}}" required disabled>
</label>
</div>
{{end}}
<p class="missing" data-purchase-empty hidden>没有选择可采购的明细,请关闭后重新勾选。</p>
</div>
<div class="modal-foot">
<button type="button" data-modal-close>取消</button>
<button type="submit" class="primary" {{if not .AssignableClients}}disabled{{end}}>确认创建</button>
</div>
</form>
</div>
</div>
<p class="hint">
每行只显示当前最需要完成的下一步;双击行或点击“下一步”按钮可以打开处理详情。
</p>