feat: 采购价格仅展示选定规格 (#113)

This commit is contained in:
chengma
2026-08-10 17:56:03 +08:00
parent b509b15f23
commit 66565d50cc
6 changed files with 93 additions and 33 deletions
+12 -1
View File
@@ -612,6 +612,8 @@ func (h *Handler) SybMatch(c *gin.Context) {
func (h *Handler) SybCreateTask(c *gin.Context) { func (h *Handler) SybCreateTask(c *gin.Context) {
ids := c.PostFormArray("ids") ids := c.PostFormArray("ids")
prices := c.PostFormArray("max_price_yuan") prices := c.PostFormArray("max_price_yuan")
mappingKeys := c.PostFormArray("mapping_option_key")
contextVersions := c.PostFormArray("context_version")
if len(ids) == 0 { if len(ids) == 0 {
h.sybRedirect(c, "没有选择货运单明细,没有创建采购任务。") h.sybRedirect(c, "没有选择货运单明细,没有创建采购任务。")
return return
@@ -623,12 +625,21 @@ func (h *Handler) SybCreateTask(c *gin.Context) {
preFailures = append(preFailures, service.TaskCreateError{SybID: sybID, Reason: "人民币价格上限不能为空"}) preFailures = append(preFailures, service.TaskCreateError{SybID: sybID, Reason: "人民币价格上限不能为空"})
continue 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]) cent, err := service.ParsePriceYuanToCent(prices[i])
if err != nil { if err != nil {
preFailures = append(preFailures, service.TaskCreateError{SybID: sybID, Reason: err.Error()}) preFailures = append(preFailures, service.TaskCreateError{SybID: sybID, Reason: err.Error()})
continue 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( result, err := service.CreatePurchaseTasksWithOptions(
h.db, currentUser(c), requests, h.db, currentUser(c), requests,
+8
View File
@@ -159,6 +159,8 @@ func mappingIsValid(context repository.SybOrderContext) bool {
type PurchaseTaskRequest struct { type PurchaseTaskRequest struct {
SybID string SybID string
MaxPriceCent int64 MaxPriceCent int64
MappingOptionKey string
ContextVersion string
} }
const LivePurchaseConfirmation = "创建未付款订单" const LivePurchaseConfirmation = "创建未付款订单"
@@ -308,6 +310,12 @@ func validatePurchaseRequest(q repository.Execer, request PurchaseTaskRequest) (
if choice == nil { if choice == nil {
return "已保存的 PDD 规格已失效,请重新匹配", context, "", 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 { if context.Order.Quantity <= 0 {
return "采购数量必须大于 0", context, "", nil return "采购数量必须大于 0", context, "", nil
} }
+30
View File
@@ -146,6 +146,11 @@ func TestSybMapping_动态维度保存并复用(t *testing.T) {
if list.Total != 1 || !list.Rows[0].CanPurchase || list.Rows[0].DefaultMaxPrice != "39.90" { if list.Total != 1 || !list.Rows[0].CanPurchase || list.Rows[0].DefaultMaxPrice != "39.90" {
t.Fatalf("可采购阶段不对: %+v", list) 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) { 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) { func TestParsePriceYuanToCent_精确转换(t *testing.T) {
for raw, want := range map[string]int64{"39.90": 3990, "1": 100, "0.01": 1, "12.3": 1230} { for raw, want := range map[string]int64{"39.90": 3990, "1": 100, "0.01": 1, "12.3": 1230} {
got, err := ParsePriceYuanToCent(raw) got, err := ParsePriceYuanToCent(raw)
+14 -12
View File
@@ -932,6 +932,9 @@ type SybOrderView struct {
NeedsAttention bool NeedsAttention bool
CanPurchase bool CanPurchase bool
DefaultMaxPrice string DefaultMaxPrice string
MappedPddChoice string
MappingOptionKey string
ContextVersion string
UpdatedAt string UpdatedAt string
} }
@@ -1117,7 +1120,17 @@ func sybOrderViewFor(context repository.SybOrderContext) SybOrderView {
UpdatedAt: formatLocalTime(o.UpdatedAt)} UpdatedAt: formatLocalTime(o.UpdatedAt)}
v.Stage, v.StageText, v.StageHelp, v.ActionText = sybStageFor(context) v.Stage, v.StageText, v.StageHelp, v.ActionText = sybStageFor(context)
v.CanPurchase = v.Stage == SybStagePurchaseReady 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 v.NeedsAttention = v.Stage != SybStagePddCollecting && v.Stage != SybStageTaskCreated
if o.PriceTwdCent > 0 { if o.PriceTwdCent > 0 {
v.PriceText = fmt.Sprintf("NT$%.2f", float64(o.PriceTwdCent)/100) v.PriceText = fmt.Sprintf("NT$%.2f", float64(o.PriceTwdCent)/100)
@@ -1133,17 +1146,6 @@ func sybOrderViewFor(context repository.SybOrderContext) SybOrderView {
return v 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 是顺运宝“下一步”弹窗第一阶段需要的上下文。 // SybProcessingDetail 是顺运宝“下一步”弹窗第一阶段需要的上下文。
type SybProcessingDetail struct { type SybProcessingDetail struct {
SybID string SybID string
+6 -2
View File
@@ -192,8 +192,12 @@
{{range .Rows}} {{range .Rows}}
<div class="purchase-confirm-row" data-purchase-row="{{.SybID}}" hidden> <div class="purchase-confirm-row" data-purchase-row="{{.SybID}}" hidden>
<input type="hidden" name="ids" value="{{.SybID}}" disabled> <input type="hidden" name="ids" value="{{.SybID}}" disabled>
<div><strong>{{.OrderNo}}</strong> · {{.Title}} · {{.ProductSpec}} × {{.Quantity}}</div> <input type="hidden" name="mapping_option_key" value="{{.MappingOptionKey}}" disabled>
<label>人民币价格上限(元) <input type="hidden" name="context_version" value="{{.ContextVersion}}" disabled>
<div><strong>{{.OrderNo}}</strong> · {{.Title}} × {{.Quantity}}</div>
<div class="hint">顺运宝规格:{{.ProductSpec}}</div>
<div><strong>已选 PDD 规格:</strong>{{.MappedPddChoice}}</div>
<label>选定颜色的人民币价格上限(元)
<input type="number" name="max_price_yuan" min="0.01" step="0.01" value="{{.DefaultMaxPrice}}" required disabled> <input type="number" name="max_price_yuan" min="0.01" step="0.01" value="{{.DefaultMaxPrice}}" required disabled>
</label> </label>
</div> </div>
+5
View File
@@ -559,6 +559,11 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
的 PDD 采集价;没有价格时留空强制填写。新任务固定为真实下单(不支付),不显示执行 的 PDD 采集价;没有价格时留空强制填写。新任务固定为真实下单(不支付),不显示执行
模式选择;风险说明、复选确认和“创建未付款订单”确认短语始终显示。 模式选择;风险说明、复选确认和“创建未付款订单”确认短语始终显示。
每条确认行同时显示顺运宝规格原文和当前映射的唯一 PDD 规格组合。价格输入只对应该
组合中的选定颜色,不显示同一 PDD 商品的其他颜色或价格。批量创建时每条顺运宝明细
各自显示一个选定组合和一个价格输入;映射或商品上下文在弹窗打开后变化时,服务端拒绝
旧提交并要求刷新。
`[必须]` 校验不过的**不要静默跳过**,要列出来告诉操作员缺什么: `[必须]` 校验不过的**不要静默跳过**,要列出来告诉操作员缺什么:
- 同一批中合法行可以创建,失败行逐条显示明细 ID 和原因;数据库错误则整批回滚。 - 同一批中合法行可以创建,失败行逐条显示明细 ID 和原因;数据库错误则整批回滚。