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) {
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,
+10 -2
View File
@@ -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
}
+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" {
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)
+30 -28
View File
@@ -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
+6 -2
View File
@@ -192,8 +192,12 @@
{{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="hidden" name="mapping_option_key" value="{{.MappingOptionKey}}" disabled>
<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>
</label>
</div>
+5
View File
@@ -559,6 +559,11 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
的 PDD 采集价;没有价格时留空强制填写。新任务固定为真实下单(不支付),不显示执行
模式选择;风险说明、复选确认和“创建未付款订单”确认短语始终显示。
每条确认行同时显示顺运宝规格原文和当前映射的唯一 PDD 规格组合。价格输入只对应该
组合中的选定颜色,不显示同一 PDD 商品的其他颜色或价格。批量创建时每条顺运宝明细
各自显示一个选定组合和一个价格输入;映射或商品上下文在弹窗打开后变化时,服务端拒绝
旧提交并要求刷新。
`[必须]` 校验不过的**不要静默跳过**,要列出来告诉操作员缺什么:
- 同一批中合法行可以创建,失败行逐条显示明细 ID 和原因;数据库错误则整批回滚。