diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index 1fd5e72..367227d 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -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 批量删除货运单。 diff --git a/admin/repository/client.go b/admin/repository/client.go index de49c80..c5909fc 100644 --- a/admin/repository/client.go +++ b/admin/repository/client.go @@ -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) diff --git a/admin/repository/mapping.go b/admin/repository/mapping.go new file mode 100644 index 0000000..8269f4f --- /dev/null +++ b/admin/repository/mapping.go @@ -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 +} diff --git a/admin/repository/syb.go b/admin/repository/syb.go index da6bc38..4a35633 100644 --- a/admin/repository/syb.go +++ b/admin/repository/syb.go @@ -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 diff --git a/admin/repository/task.go b/admin/repository/task.go index b5250c6..748c282 100644 --- a/admin/repository/task.go +++ b/admin/repository/task.go @@ -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 +} diff --git a/admin/service/purchase_workflow.go b/admin/service/purchase_workflow.go new file mode 100644 index 0000000..b46ca7a --- /dev/null +++ b/admin/service/purchase_workflow.go @@ -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 ©, 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, ¢); 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 +} diff --git a/admin/service/purchase_workflow_test.go b/admin/service/purchase_workflow_test.go new file mode 100644 index 0000000..4432daf --- /dev/null +++ b/admin/service/purchase_workflow_test.go @@ -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) + } + } +} diff --git a/admin/service/service.go b/admin/service/service.go index 78fe978..815cd06 100644 --- a/admin/service/service.go +++ b/admin/service/service.go @@ -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 登记或更新一台客户端。 diff --git a/admin/service/syb.go b/admin/service/syb.go index 67e9443..ba95174 100644 --- a/admin/service/syb.go +++ b/admin/service/syb.go @@ -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) diff --git a/admin/static/css/app.css b/admin/static/css/app.css index 75dff42..da6c75f 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -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; diff --git a/admin/static/js/app.js b/admin/static/js/app.js index 27ac0f5..2e23827 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -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(); diff --git a/admin/templates/syb/detail_modal.html b/admin/templates/syb/detail_modal.html index 5f673df..4eef514 100644 --- a/admin/templates/syb/detail_modal.html +++ b/admin/templates/syb/detail_modal.html @@ -76,6 +76,34 @@ {{if eq .CollectStatus "collecting"}}采集任务正在执行,请稍后刷新。{{else}}PDD 数据已采集,下一步匹配采购规格。{{end}} {{end}} + + {{if eq .CollectStatus "collected"}} +

匹配采购规格

+ {{if .PddChoices}} +
+ + + + + +
+ + + PDD 维度来自采集结果:{{range $i, $name := .PddDimensionNames}}{{if $i}} / {{end}}{{$name}}{{end}}。保存后相同蝦皮 SKU + 当前 PDD 商品会复用。 +
+ {{if and (not .MappingValid) .PddChoices}}

尚无有效映射,或原映射已不在最新采集结果中,请重新选择。

{{end}} +
+
+ {{else}} +

最新采集结果里没有可购买的规格组合,请核对 PDD 商品或重新采集。

+ {{end}} + {{if .HasActiveTask}}

这条明细已有未结束采购任务,请到“采集采购”页面查看,不要重复创建。

{{end}} + {{end}} {{end}} {{end}} diff --git a/admin/templates/syb/list.html b/admin/templates/syb/list.html index 5a88b12..385250c 100644 --- a/admin/templates/syb/list.html +++ b/admin/templates/syb/list.html @@ -21,13 +21,7 @@ -
- - - - - -
+
@@ -110,7 +104,7 @@ {{range .Rows}} - {{.SybID}} @@ -145,6 +139,47 @@ +
+

每行只显示当前最需要完成的下一步;双击行或点击“下一步”按钮可以打开处理详情。

diff --git a/docs/admin/01-requirements.md b/docs/admin/01-requirements.md index a226966..bef2a74 100644 --- a/docs/admin/01-requirements.md +++ b/docs/admin/01-requirements.md @@ -200,7 +200,7 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 | 数量 | | | 价格 | 蝦皮售价,**台币分**,见 §7 | | 图片 | 存 URL,表格里显示缩略图 | -| 处理阶段 | 未找到蝦皮商品 / 待确认蝦皮规格 / 未关联 PDD / 待采集 / 采集中 / 采集失败 / 规格待匹配 / 可采购 | +| 处理阶段 | 未找到蝦皮商品 / 待确认蝦皮规格 / 未关联 PDD / 待采集 / 采集中 / 采集失败 / 规格待匹配 / 可采购 / 采购数据异常 / 已创建任务 | | 下一步 | 每行只显示当前最需要完成的一个操作 | | 更新时间 | | @@ -212,14 +212,16 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 - `shopee_sku_id` 只表示“蝦皮规格已确认”,不等于 PDD 规格已经匹配。完整处理阶段还要检查当前 PDD 关联、采集状态和 `sku_mappings`。 - 蝦皮 SKU 确认后,可在同一弹窗填写完整 PDD 链接并创建采集任务;链接关联规则与蝦皮详情完全一致。 -- 左边显示蝦皮的颜色尺码,右边显示该商品 `pdd_data` 里的 PDD 颜色尺码; +- 显示蝦皮规格原文和当前 PDD 商品 `skus_json` 里的动态规格组合与人民币价格; +- PDD 维度来自采集结果的 `dimensions`,不得写死成颜色、尺码两个维度; - 操作员选好对应关系,点保存; - `[必须]` 保存的是**可复用的 SKU 映射**,不是这一张订单的临时数据。 下次遇到同一个蝦皮 SKU 自动带出,操作员只需确认。 -- `[必须]` 该商品尚未采集(`pdd_data` 为空)时,弹窗要明确提示"请先到蝦皮数据模块采集", +- `[必须]` 该商品尚未采集(`skus_json` 为空)时,弹窗直接展示关联和创建采集任务入口, 而不是显示一个空列表让人困惑。 -**创建采购任务:** 勾选若干行 → 点按钮 → 校验通过后生成任务。校验规则见 §5。 +**创建采购任务:** 只有映射仍有效且没有进行中采购任务的行可勾选。确认弹窗要求 +选择当前账号可见的客户端,并逐行确认人民币价格上限;服务端仍按 §5 全量复核。 **底部状态条:** 最近同步时间、待匹配条数。 @@ -330,11 +332,13 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 `[必须]` 下面任何一条不满足就不允许创建,并明确告诉操作员缺什么: 1. 该蝦皮商品已填 PDD 链接; -2. 该蝦皮商品已采集成功(`pdd_data` 非空); +2. 当前 PDD 商品已采集成功(`skus_json` 非空); 3. 该蝦皮 SKU 已有 PDD 规格映射; 4. 数量大于 0; -5. **价格上限已填且大于 0** —— 默认从 `pdd_data` 里该 SKU 的价格带出,操作员可改,但不允许为空; -6. 已选择分配的客户端。 +5. **价格上限已填且大于 0** —— 默认从当前映射的 PDD 规格价格带出,操作员可改,但不允许为空; +6. 已选择分配的客户端; +7. 所选客户端存在且在当前账号可见范围内; +8. 同一顺运宝明细没有 `pending` / `assigned` / `claimed` 的采购任务。 第 5 条是硬要求:Client 契约规定采购任务必须带明确的价格保护 (见 [Client 契约](../client/04-admin-api-contract.md) §4),没有它 Client 会拒绝执行。 @@ -396,7 +400,7 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 | PDD 采购价、价格上限 | 人民币 | `max_price_cent` | `[必须]` **两种币种不得混用,不得互相换算后覆盖原值。** -采购任务里的价格上限是**人民币**,来源是采集回来的 PDD 价格, +采购任务里的价格上限是**人民币**,默认来源是采集回来的 PDD 价格,并由采购员逐行确认, 和蝦皮的台币售价没有换算关系。 `[待定]` 是否需要展示汇率或利润,待业务确认。MVP 不做。 diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index fe6f289..40c759f 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -455,11 +455,12 @@ CREATE INDEX idx_syb_orders_list ON syb_orders(updated_at DESC, syb_id DESC); **"编号能对上"和"本地一定查得到"是两回事,别混。** -**"匹配状态"是派生的,不存字段**:`shopee_sku_id` 非空就是"已匹配"(本工单未做 -`sku_mappings` 联查复用,规格匹配是后续工单的范围)。 +**处理阶段是派生的,不存字段**:`shopee_sku_id` 非空只表示蝦皮规格已确认。 +PDD 规格是否已匹配要联查 `sku_mappings`,同时限定 `shopee_sku_id` 和当前 +`pdd_goods_id`,并确认 `pdd_option_key` 仍存在于最新 `skus_json`;选项消失后按待匹配处理。 -`[必须]` **`shopee_sku_id` 顺运宝同步绝不能覆盖**(工单 #46)。它是规格匹配的 -结果(人工确认或自动匹配产生),顺运宝那边根本没有这个值(顺运宝只给商品级 `productId`, +`[必须]` **`shopee_sku_id` 顺运宝同步绝不能覆盖**(工单 #46)。它是蝦皮规格识别 +结果(人工确认或确定性唯一识别产生),顺运宝那边根本没有这个值(顺运宝只给商品级 `productId`, 不含蝦皮規格ID,见 §5.1 下方接口对照)。`repository.UpsertSybOrder` 的 `ON CONFLICT DO UPDATE SET` 里不出现这一列,新建行时才会写它(此时通常是空值)。 diff --git a/docs/admin/04-client-api.md b/docs/admin/04-client-api.md index 3ca31f4..502c1b3 100644 --- a/docs/admin/04-client-api.md +++ b/docs/admin/04-client-api.md @@ -64,6 +64,9 @@ X-Client-Id: client-001 排序 `ORDER BY (assigned_client IS NULL), priority DESC, created_at` —— **指定给本机的优先**; + + 网页创建的采购任务固定走第一种:创建前已经校验当前账号对客户端的可见范围, + 并写入 `pdd_options`、`quantity` 和人民币 `max_price_cent`。这不改变 Client 接口字段。 3. 没有就返回 `204 No Content`; 4. 有就把 `status` 改成 `claimed`、记 `claimed_at`,返回任务。 diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 13a5b95..9dd9110 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -476,7 +476,7 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表 - 图片显示小缩略图。`[必须]` 存 URL,不要把图片塞进数据库。 - 处理阶段是按当前关联事实**算出来的**,不是存一份容易过期的状态字段。阶段至少区分: 未找到蝦皮商品、待确认蝦皮规格、未关联 PDD、PDD 待采集、采集中、采集失败、 - 规格待匹配和可采购。`shopee_sku_id` 非空只说明蝦皮规格已确认。 + 规格待匹配、可采购、采购数据异常和已创建任务。`shopee_sku_id` 非空只说明蝦皮规格已确认。 - 每行显示一个有文字的“下一步”按钮;双击行和按钮打开同一个服务端渲染处理弹窗。 - 规格原文完全一致,或解析后的颜色尺码只有一个唯一候选时,系统可以自动确认 蝦皮 SKU;零候选或多候选必须由采购员选择,不能模糊猜测。 @@ -510,21 +510,25 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表 `[必须]` 几条硬规则: -- 右侧下拉的选项来自该商品 `pdd_data` 里的 `dimensions`, +- 右侧下拉的选项来自该商品 `skus_json` 里的 `dimensions`, **不要写死"颜色/尺码"两个维度**——PDD 商品可能有第三个维度。 - **打开时先查 `sku_mappings`**,有记录就自动带出并提示"已自动带出"。 这是省人工的关键:同一个蝦皮 SKU 只需人工匹配一次。 -- 该商品**还没采集**(`pdd_data` 为空)时,不要显示一个空下拉让人困惑, - 直接提示:"该商品尚未采集 PDD 数据,请先到『蝦皮数据』填写链接并采集", - 并给一个跳转链接。 +- 查询必须同时带当前 `pdd_goods_id`,并核对选项仍存在且可购买;换品或选项消失时回到待匹配。 +- 该商品**还没采集**(`skus_json` 为空)时,不要显示空下拉,直接展示关联和创建采集任务入口。 - 保存写的是 `sku_mappings`(可复用),**不是这一张订单的临时数据**。 ### 6.4 创建采购任务 -勾选若干行 → 点按钮 → 逐条校验(见 [01 需求](01-requirements.md) §5)。 +只有“可创建采购任务”的行可勾选。勾选后打开服务端渲染的确认弹窗:选择当前账号 +可见的客户端,并逐行确认人民币价格上限。默认值来自映射规格的 PDD 采集价;没有价格时留空强制填写。 `[必须]` 校验不过的**不要静默跳过**,要列出来告诉操作员缺什么: +- 同一批中合法行可以创建,失败行逐条显示明细 ID 和原因;数据库错误则整批回滚。 +- 同一明细已有 `pending`、`assigned` 或 `claimed` 的采购任务时拒绝重复创建。 +- 顺运宝列表继续显示台币售价,但绝不把它带入人民币价格上限。 + ```text 成功创建 3 个任务。以下 2 条未创建: · SO-...-007 该商品未填写 PDD 链接 @@ -532,7 +536,7 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表 ``` `[必须]` 创建前弹出确认框,让操作员选**分配给哪个客户端**,并确认价格上限。 -价格上限默认从 `pdd_data` 带出,可改,**不允许为空**。 +价格上限默认从当前映射的 PDD 规格采集价带出,可改,**不允许为空**。 ## 7. 采集采购页 diff --git a/docs/admin/06-quality-security.md b/docs/admin/06-quality-security.md index 5f86642..fe0268b 100644 --- a/docs/admin/06-quality-security.md +++ b/docs/admin/06-quality-security.md @@ -24,7 +24,8 @@ 再加解析失败的用例(确认 `parse_ok=0` 且不瞎猜); - **Excel 导入分行**:商品汇总行 vs SKU 行的判断; - **upsert 不覆盖人工字段**:先填 PDD 链接,再导入一次,断言链接还在; -- 任务创建校验(六条规则逐条); +- 任务创建校验:商品、采集结果、有效映射、数量、人民币价格上限、客户端可见范围逐条覆盖; +- 采购任务重复提交保护、部分业务失败、PDD 换品隔离和采集选项失效; - 金额换算显示(分 ↔ 元),边界值 0 和大额; - SKU 映射复用:第二次匹配同一 SKU 应自动带出; - 在线状态派生:`last_seen_at` 刚好在边界前后。 @@ -57,6 +58,7 @@ - **任务已重派给别人,原客户端提交仍被接受**; - 同一任务收到两个客户端的两份结果,都存下来; - 数据库从上一版本迁移,人工数据不丢。 +- 创建采购任务后通过现有 Client 契约读取,断言目标规格、数量、人民币价格上限和指定客户端完整; 中间三条是 [04](04-client-api.md) §4.1 那条最容易写错的规则,**必须有测试盯着**。