diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index 1431027..dcd0b3f 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -612,6 +612,8 @@ func (h *Handler) SybMatch(c *gin.Context) { func (h *Handler) SybCreateTask(c *gin.Context) { ids := c.PostFormArray("ids") prices := c.PostFormArray("max_price_yuan") + mappingKeys := c.PostFormArray("mapping_option_key") + contextVersions := c.PostFormArray("context_version") if len(ids) == 0 { h.sybRedirect(c, "没有选择货运单明细,没有创建采购任务。") return @@ -623,12 +625,21 @@ func (h *Handler) SybCreateTask(c *gin.Context) { preFailures = append(preFailures, service.TaskCreateError{SybID: sybID, Reason: "人民币价格上限不能为空"}) continue } + if i >= len(mappingKeys) || i >= len(contextVersions) || + strings.TrimSpace(mappingKeys[i]) == "" || strings.TrimSpace(contextVersions[i]) == "" { + 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}) + requests = append(requests, service.PurchaseTaskRequest{ + SybID: sybID, MaxPriceCent: cent, + MappingOptionKey: strings.TrimSpace(mappingKeys[i]), + ContextVersion: strings.TrimSpace(contextVersions[i]), + }) } result, err := service.CreatePurchaseTasksWithOptions( h.db, currentUser(c), requests, diff --git a/admin/service/purchase_workflow.go b/admin/service/purchase_workflow.go index cbafe28..23490ad 100644 --- a/admin/service/purchase_workflow.go +++ b/admin/service/purchase_workflow.go @@ -157,8 +157,10 @@ func mappingIsValid(context repository.SybOrderContext) bool { // PurchaseTaskRequest 是一条采购任务的人工确认输入。价格单位固定为人民币分。 type PurchaseTaskRequest struct { - SybID string - MaxPriceCent int64 + SybID string + MaxPriceCent int64 + MappingOptionKey string + ContextVersion string } const LivePurchaseConfirmation = "创建未付款订单" @@ -308,6 +310,12 @@ func validatePurchaseRequest(q repository.Execer, request PurchaseTaskRequest) ( if choice == nil { return "已保存的 PDD 规格已失效,请重新匹配", context, "", nil } + if request.MappingOptionKey != "" && request.MappingOptionKey != context.MappingOptionKey { + return "规格映射已变化,请刷新页面后重新确认", context, "", nil + } + if request.ContextVersion != "" && request.ContextVersion != mappingContextVersion(*context) { + return "商品或规格数据已变化,请刷新页面后重新确认", context, "", nil + } if context.Order.Quantity <= 0 { return "采购数量必须大于 0", context, "", nil } diff --git a/admin/service/purchase_workflow_test.go b/admin/service/purchase_workflow_test.go index ff3e165..e5c92b5 100644 --- a/admin/service/purchase_workflow_test.go +++ b/admin/service/purchase_workflow_test.go @@ -146,6 +146,11 @@ func TestSybMapping_动态维度保存并复用(t *testing.T) { if list.Total != 1 || !list.Rows[0].CanPurchase || list.Rows[0].DefaultMaxPrice != "39.90" { t.Fatalf("可采购阶段不对: %+v", list) } + row := list.Rows[0] + if row.MappedPddChoice != "颜色:黑色 / 尺码:M码 / 款式:常规" || + strings.Contains(row.MappedPddChoice, "白色") || row.MappingOptionKey != key || row.ContextVersion == "" { + t.Fatalf("采购确认只能带出当前映射组合,实际 %+v", row) + } } func TestSybMapping_同商品同规格第二条明细自动命中(t *testing.T) { @@ -446,6 +451,31 @@ func TestCreatePurchaseTasksWithOptions_页面打开后客户端离线会被拒 } } +func TestCreatePurchaseTasksWithOptions_拒绝弹窗打开后变化的规格映射(t *testing.T) { + db := newTestDB(t) + blackKey := seedPurchasableWorkflow(t, db, "SYB-STALE") + SaveSybMapping(db, "SYB-STALE", blackKey, "USR-1") + context, err := repository.GetSybOrderContext(db, "SYB-STALE") + if err != nil || context == nil { + t.Fatal(err) + } + oldVersion := mappingContextVersion(*context) + whiteKey, _ := OptionKey(map[string]string{"color": "白色", "size": "L码", "style": "加绒"}) + SaveSybMapping(db, "SYB-STALE", whiteKey, "USR-2") + admin, _, _ := prepareClientAssignmentUsers(t, db) + RegisterClient(db, model.Client{ClientID: "CLIENT-LIVE", Capabilities: `{"purchase_mode":"live"}`}, true) + + result, err := CreatePurchaseTasksWithOptions(db, admin, + []PurchaseTaskRequest{{SybID: "SYB-STALE", MaxPriceCent: 3990, + MappingOptionKey: blackKey, ContextVersion: oldVersion}}, + PurchaseTaskOptions{ClientID: "CLIENT-LIVE", ExecutionMode: model.TaskExecutionLive, + LiveAcknowledged: true, LiveConfirmation: LivePurchaseConfirmation}) + if err != nil || result.Created != 0 || len(result.Failures) != 1 || + !strings.Contains(result.Failures[0].Reason, "已变化") { + 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) diff --git a/admin/service/syb.go b/admin/service/syb.go index 20dfd48..295b3bd 100644 --- a/admin/service/syb.go +++ b/admin/service/syb.go @@ -917,22 +917,25 @@ 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 - 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 + SybID string + OrderNo string + Title string + ProductSpec string + ShopeeGoodsID string + Quantity int + PriceText string // "NT$239.00",和人民币价格一眼分得清 + ImageURL string + Stage string + StageText string + StageHelp string + ActionText string + NeedsAttention bool + CanPurchase bool + DefaultMaxPrice string + MappedPddChoice string + MappingOptionKey string + ContextVersion string + UpdatedAt string } const ( @@ -1117,7 +1120,17 @@ func sybOrderViewFor(context repository.SybOrderContext) SybOrderView { UpdatedAt: formatLocalTime(o.UpdatedAt)} v.Stage, v.StageText, v.StageHelp, v.ActionText = sybStageFor(context) v.CanPurchase = v.Stage == SybStagePurchaseReady - v.DefaultMaxPrice = defaultMaxPrice(context) + if v.CanPurchase { + v.MappingOptionKey = context.MappingOptionKey + v.ContextVersion = mappingContextVersion(context) + choice, err := findPddChoice(context.PddSkusJSON, context.MappingOptionKey) + if err == nil && choice != nil { + v.MappedPddChoice = choice.Label + if choice.HasPrice { + v.DefaultMaxPrice = fmt.Sprintf("%d.%02d", choice.PriceCent/100, choice.PriceCent%100) + } + } + } v.NeedsAttention = v.Stage != SybStagePddCollecting && v.Stage != SybStageTaskCreated if o.PriceTwdCent > 0 { v.PriceText = fmt.Sprintf("NT$%.2f", float64(o.PriceTwdCent)/100) @@ -1133,17 +1146,6 @@ func sybOrderViewFor(context repository.SybOrderContext) SybOrderView { 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) -} - // SybProcessingDetail 是顺运宝“下一步”弹窗第一阶段需要的上下文。 type SybProcessingDetail struct { SybID string diff --git a/admin/templates/syb/list.html b/admin/templates/syb/list.html index d3441c8..ccdf9a3 100644 --- a/admin/templates/syb/list.html +++ b/admin/templates/syb/list.html @@ -192,8 +192,12 @@ {{range .Rows}} diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 1b41fa7..ed9b247 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -559,6 +559,11 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表 的 PDD 采集价;没有价格时留空强制填写。新任务固定为真实下单(不支付),不显示执行 模式选择;风险说明、复选确认和“创建未付款订单”确认短语始终显示。 +每条确认行同时显示顺运宝规格原文和当前映射的唯一 PDD 规格组合。价格输入只对应该 +组合中的选定颜色,不显示同一 PDD 商品的其他颜色或价格。批量创建时每条顺运宝明细 +各自显示一个选定组合和一个价格输入;映射或商品上下文在弹窗打开后变化时,服务端拒绝 +旧提交并要求刷新。 + `[必须]` 校验不过的**不要静默跳过**,要列出来告诉操作员缺什么: - 同一批中合法行可以创建,失败行逐条显示明细 ID 和原因;数据库错误则整批回滚。