feat: add manual fulfillment order creation
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
-- 区分开放 API、商户后台人工和联调测试创建的订单,供后台按来源管理。
|
||||||
|
ALTER TABLE fulfillment_orders
|
||||||
|
ADD COLUMN IF NOT EXISTS order_source VARCHAR(16) NOT NULL DEFAULT 'api';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fulfillment_orders_merchant_source
|
||||||
|
ON fulfillment_orders (merchant_id, order_source);
|
||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"affiliate_dash/internal/middleware"
|
"affiliate_dash/internal/middleware"
|
||||||
@@ -76,14 +77,64 @@ func (h *MerchantHandler) UpdateProductStatus(c *gin.Context) {
|
|||||||
|
|
||||||
func (h *MerchantHandler) ListOrders(c *gin.Context) {
|
func (h *MerchantHandler) ListOrders(c *gin.Context) {
|
||||||
page, size := pageParams(c)
|
page, size := pageParams(c)
|
||||||
list, total, err := h.fulfillmentSvc.ListOrders(middleware.GetMerchantID(c), page, size, c.Query("order_status"))
|
orderSource := strings.TrimSpace(c.Query("order_source"))
|
||||||
|
list, total, err := h.fulfillmentSvc.ListOrders(middleware.GetMerchantID(c), page, size, c.Query("order_status"), orderSource)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if orderSource != "" && err.Error() == "无效的订单来源" {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
response.ServerError(c, err.Error())
|
response.ServerError(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.Page(c, list, total, page, size)
|
response.Page(c, list, total, page, size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type merchantManualOrderReq struct {
|
||||||
|
ClientOrderNo string `json:"client_order_no" binding:"required,max=96"`
|
||||||
|
SKU string `json:"sku" binding:"required,max=96"`
|
||||||
|
Quantity int64 `json:"quantity"`
|
||||||
|
BuyerReference string `json:"buyer_reference" binding:"max=128"`
|
||||||
|
GameAccount string `json:"game_account" binding:"max=128"`
|
||||||
|
Note string `json:"note" binding:"max=512"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateManualOrder 在商户后台创建真实订单;扣款、库存和回调与开放 API 下单保持一致。
|
||||||
|
func (h *MerchantHandler) CreateManualOrder(c *gin.Context) {
|
||||||
|
var req merchantManualOrderReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "参数错误:商户单号与商品必填")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := map[string]string{}
|
||||||
|
if gameAccount := strings.TrimSpace(req.GameAccount); gameAccount != "" {
|
||||||
|
data["game_account"] = gameAccount
|
||||||
|
}
|
||||||
|
if note := strings.TrimSpace(req.Note); note != "" {
|
||||||
|
data["manual_note"] = note
|
||||||
|
}
|
||||||
|
result, err := h.fulfillmentSvc.CreateManualOrder(service.CreateManualOrderInput{
|
||||||
|
MerchantID: middleware.GetMerchantID(c),
|
||||||
|
ActorUserID: middleware.GetUserID(c),
|
||||||
|
ClientOrderNo: req.ClientOrderNo,
|
||||||
|
SKU: req.SKU,
|
||||||
|
Quantity: req.Quantity,
|
||||||
|
BuyerReference: req.BuyerReference,
|
||||||
|
RequestData: data,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canShip, cannotShipReason := service.CanFulfill(result.Order)
|
||||||
|
response.OK(c, gin.H{
|
||||||
|
"order": result.Order,
|
||||||
|
"idempotent": result.Idempotent,
|
||||||
|
"can_ship": canShip,
|
||||||
|
"cannot_ship_reason": cannotShipReason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type merchantTestOrderReq struct {
|
type merchantTestOrderReq struct {
|
||||||
SKU string `json:"sku" binding:"required"`
|
SKU string `json:"sku" binding:"required"`
|
||||||
BuyerReference string `json:"buyer_reference"`
|
BuyerReference string `json:"buyer_reference"`
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ func buildOpenOrderResponse(order *model.FulfillmentOrder) gin.H {
|
|||||||
"order_no": order.OrderNo,
|
"order_no": order.OrderNo,
|
||||||
"client_order_no": order.ClientOrderNo,
|
"client_order_no": order.ClientOrderNo,
|
||||||
"order_status": order.OrderStatus,
|
"order_status": order.OrderStatus,
|
||||||
|
"order_source": order.OrderSource,
|
||||||
"can_ship": canShip,
|
"can_ship": canShip,
|
||||||
"cannot_ship_reason": reason,
|
"cannot_ship_reason": reason,
|
||||||
"product": gin.H{
|
"product": gin.H{
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ const (
|
|||||||
OrderStatusShipFailed = "ship_failed" // 发货失败(可重试)
|
OrderStatusShipFailed = "ship_failed" // 发货失败(可重试)
|
||||||
OrderStatusCancelled = "cancelled" // 已取消并退款
|
OrderStatusCancelled = "cancelled" // 已取消并退款
|
||||||
|
|
||||||
|
OrderSourceAPI = "api" // 商户开放接口创建
|
||||||
|
OrderSourceManual = "manual" // 商户后台人工创建
|
||||||
|
OrderSourceTest = "test" // 后台联调测试订单
|
||||||
|
|
||||||
WalletLedgerCredit = "credit"
|
WalletLedgerCredit = "credit"
|
||||||
WalletLedgerDebit = "debit"
|
WalletLedgerDebit = "debit"
|
||||||
WalletLedgerRefund = "refund"
|
WalletLedgerRefund = "refund"
|
||||||
@@ -206,6 +210,7 @@ type FulfillmentOrder struct {
|
|||||||
Amount int64 `gorm:"not null" json:"amount"`
|
Amount int64 `gorm:"not null" json:"amount"`
|
||||||
Currency string `gorm:"size:12;not null;default:POINT" json:"currency"`
|
Currency string `gorm:"size:12;not null;default:POINT" json:"currency"`
|
||||||
OrderStatus string `gorm:"size:16;not null;default:paid;index" json:"order_status"`
|
OrderStatus string `gorm:"size:16;not null;default:paid;index" json:"order_status"`
|
||||||
|
OrderSource string `gorm:"size:16;not null;default:api;index" json:"order_source"`
|
||||||
BuyerReference string `gorm:"size:128" json:"buyer_reference"`
|
BuyerReference string `gorm:"size:128" json:"buyer_reference"`
|
||||||
RequestFingerprint string `gorm:"size:64;not null;default:'';index" json:"-"`
|
RequestFingerprint string `gorm:"size:64;not null;default:'';index" json:"-"`
|
||||||
RequestData string `gorm:"type:text" json:"request_data"`
|
RequestData string `gorm:"type:text" json:"request_data"`
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ func Setup(h *Handlers) *gin.Engine {
|
|||||||
merchant.GET("/products", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), h.Merchant.ListProducts)
|
merchant.GET("/products", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), h.Merchant.ListProducts)
|
||||||
merchant.PATCH("/products/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateProductStatus)
|
merchant.PATCH("/products/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateProductStatus)
|
||||||
merchant.GET("/orders", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), h.Merchant.ListOrders)
|
merchant.GET("/orders", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), h.Merchant.ListOrders)
|
||||||
|
merchant.POST("/orders/manual", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateManualOrder)
|
||||||
merchant.POST("/orders/test", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateTestOrder)
|
merchant.POST("/orders/test", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateTestOrder)
|
||||||
merchant.GET("/orders/:order_no/delivery-link", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.GetDeliveryLink)
|
merchant.GET("/orders/:order_no/delivery-link", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.GetDeliveryLink)
|
||||||
merchant.POST("/orders/:order_no/delivery-link/revoke", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.RevokeDeliveryLink)
|
merchant.POST("/orders/:order_no/delivery-link/revoke", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.RevokeDeliveryLink)
|
||||||
|
|||||||
@@ -26,11 +26,13 @@ func NewFulfillmentService(db *gorm.DB, callbacks *CallbackService) *Fulfillment
|
|||||||
type CreateFulfillmentOrderInput struct {
|
type CreateFulfillmentOrderInput struct {
|
||||||
MerchantID uint
|
MerchantID uint
|
||||||
APIClientID uint
|
APIClientID uint
|
||||||
|
ActorUserID uint
|
||||||
ClientOrderNo string
|
ClientOrderNo string
|
||||||
SKU string
|
SKU string
|
||||||
Quantity int64
|
Quantity int64
|
||||||
BuyerReference string
|
BuyerReference string
|
||||||
RequestData interface{}
|
RequestData interface{}
|
||||||
|
OrderSource string
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateFulfillmentOrderResult struct {
|
type CreateFulfillmentOrderResult struct {
|
||||||
@@ -47,11 +49,35 @@ type CreateTestOrderInput struct {
|
|||||||
OrderStatus string
|
OrderStatus string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateManualOrderInput 是商户后台创建真实订单的输入。其账务、库存与回调规则与开放 API 下单一致。
|
||||||
|
type CreateManualOrderInput struct {
|
||||||
|
MerchantID uint
|
||||||
|
ActorUserID uint
|
||||||
|
ClientOrderNo string
|
||||||
|
SKU string
|
||||||
|
Quantity int64
|
||||||
|
BuyerReference string
|
||||||
|
RequestData interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FulfillmentService) CreateManualOrder(in CreateManualOrderInput) (*CreateFulfillmentOrderResult, error) {
|
||||||
|
return s.CreateOrder(CreateFulfillmentOrderInput{
|
||||||
|
MerchantID: in.MerchantID,
|
||||||
|
ActorUserID: in.ActorUserID,
|
||||||
|
ClientOrderNo: in.ClientOrderNo,
|
||||||
|
SKU: in.SKU,
|
||||||
|
Quantity: in.Quantity,
|
||||||
|
BuyerReference: in.BuyerReference,
|
||||||
|
RequestData: in.RequestData,
|
||||||
|
OrderSource: model.OrderSourceManual,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*CreateFulfillmentOrderResult, error) {
|
func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*CreateFulfillmentOrderResult, error) {
|
||||||
in.ClientOrderNo = strings.TrimSpace(in.ClientOrderNo)
|
in.ClientOrderNo = strings.TrimSpace(in.ClientOrderNo)
|
||||||
in.SKU = strings.TrimSpace(in.SKU)
|
in.SKU = strings.TrimSpace(in.SKU)
|
||||||
if in.MerchantID == 0 || in.APIClientID == 0 {
|
if in.MerchantID == 0 || (in.APIClientID == 0 && in.ActorUserID == 0) {
|
||||||
return nil, errors.New("无效的商户或 API 客户端")
|
return nil, errors.New("无效的商户或下单身份")
|
||||||
}
|
}
|
||||||
if in.ClientOrderNo == "" || len(in.ClientOrderNo) > 96 {
|
if in.ClientOrderNo == "" || len(in.ClientOrderNo) > 96 {
|
||||||
return nil, errors.New("client_order_no 不能为空且最长 96 位")
|
return nil, errors.New("client_order_no 不能为空且最长 96 位")
|
||||||
@@ -65,6 +91,15 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat
|
|||||||
if in.Quantity < 1 {
|
if in.Quantity < 1 {
|
||||||
return nil, errors.New("quantity 必须大于零")
|
return nil, errors.New("quantity 必须大于零")
|
||||||
}
|
}
|
||||||
|
orderSource := strings.TrimSpace(in.OrderSource)
|
||||||
|
if orderSource == "" {
|
||||||
|
orderSource = model.OrderSourceAPI
|
||||||
|
}
|
||||||
|
switch orderSource {
|
||||||
|
case model.OrderSourceAPI, model.OrderSourceManual:
|
||||||
|
default:
|
||||||
|
return nil, errors.New("无效的订单来源")
|
||||||
|
}
|
||||||
requestData := ""
|
requestData := ""
|
||||||
if in.RequestData != nil {
|
if in.RequestData != nil {
|
||||||
raw, err := json.Marshal(in.RequestData)
|
raw, err := json.Marshal(in.RequestData)
|
||||||
@@ -155,6 +190,7 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat
|
|||||||
Amount: totalAmount,
|
Amount: totalAmount,
|
||||||
Currency: product.Currency,
|
Currency: product.Currency,
|
||||||
OrderStatus: model.OrderStatusPaid,
|
OrderStatus: model.OrderStatusPaid,
|
||||||
|
OrderSource: orderSource,
|
||||||
BuyerReference: in.BuyerReference,
|
BuyerReference: in.BuyerReference,
|
||||||
RequestFingerprint: fingerprint,
|
RequestFingerprint: fingerprint,
|
||||||
RequestData: requestData,
|
RequestData: requestData,
|
||||||
@@ -163,6 +199,10 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
idempotencyKey := in.ClientOrderNo
|
idempotencyKey := in.ClientOrderNo
|
||||||
|
ledgerNote := "开放接口下单扣款(含平台手续费)"
|
||||||
|
if orderSource == model.OrderSourceManual {
|
||||||
|
ledgerNote = "商户后台手动下单扣款(含平台手续费)"
|
||||||
|
}
|
||||||
if err := tx.Create(&model.WalletLedgerEntry{
|
if err := tx.Create(&model.WalletLedgerEntry{
|
||||||
MerchantID: in.MerchantID,
|
MerchantID: in.MerchantID,
|
||||||
WalletAccountID: wallet.ID,
|
WalletAccountID: wallet.ID,
|
||||||
@@ -173,7 +213,7 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat
|
|||||||
ReferenceType: "fulfillment_order",
|
ReferenceType: "fulfillment_order",
|
||||||
ReferenceNo: order.OrderNo,
|
ReferenceNo: order.OrderNo,
|
||||||
IdempotencyKey: &idempotencyKey,
|
IdempotencyKey: &idempotencyKey,
|
||||||
Note: "开放接口下单扣款(含平台手续费)",
|
Note: ledgerNote,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -183,7 +223,11 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := writeAudit(tx, &in.MerchantID, nil, &in.APIClientID, "open_order.create", "fulfillment_order", order.OrderNo, map[string]interface{}{"client_order_no": in.ClientOrderNo, "sku": in.SKU}); err != nil {
|
action := "open_order.create"
|
||||||
|
if orderSource == model.OrderSourceManual {
|
||||||
|
action = "merchant_order.manual_create"
|
||||||
|
}
|
||||||
|
if err := writeAudit(tx, &in.MerchantID, optionalUint(in.ActorUserID), optionalUint(in.APIClientID), action, "fulfillment_order", order.OrderNo, map[string]interface{}{"client_order_no": in.ClientOrderNo, "sku": in.SKU, "source": orderSource}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if s.callbacks != nil {
|
if s.callbacks != nil {
|
||||||
@@ -289,6 +333,7 @@ func (s *FulfillmentService) CreateTestOrder(in CreateTestOrderInput) (*model.Fu
|
|||||||
Amount: 0,
|
Amount: 0,
|
||||||
Currency: product.Currency,
|
Currency: product.Currency,
|
||||||
OrderStatus: in.OrderStatus,
|
OrderStatus: in.OrderStatus,
|
||||||
|
OrderSource: model.OrderSourceTest,
|
||||||
BuyerReference: in.BuyerReference,
|
BuyerReference: in.BuyerReference,
|
||||||
RequestData: string(rawRequestData),
|
RequestData: string(rawRequestData),
|
||||||
}
|
}
|
||||||
@@ -511,6 +556,7 @@ func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} {
|
|||||||
"amount": order.Amount,
|
"amount": order.Amount,
|
||||||
"currency": order.Currency,
|
"currency": order.Currency,
|
||||||
"order_status": normalizeOrderStatus(order),
|
"order_status": normalizeOrderStatus(order),
|
||||||
|
"order_source": order.OrderSource,
|
||||||
"can_ship": canShip,
|
"can_ship": canShip,
|
||||||
"cannot_ship_reason": cannotShipReason,
|
"cannot_ship_reason": cannotShipReason,
|
||||||
"provider_order_no": order.ProviderOrderNo,
|
"provider_order_no": order.ProviderOrderNo,
|
||||||
|
|||||||
@@ -23,12 +23,20 @@ func (s *FulfillmentService) GetOrder(merchantID uint, orderNo string) (*model.F
|
|||||||
return &order, nil
|
return &order, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *FulfillmentService) ListOrders(merchantID uint, page, size int, orderStatus string) ([]model.FulfillmentOrder, int64, error) {
|
func (s *FulfillmentService) ListOrders(merchantID uint, page, size int, orderStatus, orderSource string) ([]model.FulfillmentOrder, int64, error) {
|
||||||
page, size = normalizePage(page, size)
|
page, size = normalizePage(page, size)
|
||||||
tx := s.db.Model(&model.FulfillmentOrder{}).Where("merchant_id = ?", merchantID)
|
tx := s.db.Model(&model.FulfillmentOrder{}).Where("merchant_id = ?", merchantID)
|
||||||
if orderStatus != "" {
|
if orderStatus != "" {
|
||||||
tx = tx.Where("order_status = ?", orderStatus)
|
tx = tx.Where("order_status = ?", orderStatus)
|
||||||
}
|
}
|
||||||
|
if orderSource != "" {
|
||||||
|
switch orderSource {
|
||||||
|
case model.OrderSourceAPI, model.OrderSourceManual, model.OrderSourceTest:
|
||||||
|
tx = tx.Where("order_source = ?", orderSource)
|
||||||
|
default:
|
||||||
|
return nil, 0, errors.New("无效的订单来源")
|
||||||
|
}
|
||||||
|
}
|
||||||
var total int64
|
var total int64
|
||||||
if err := tx.Count(&total).Error; err != nil {
|
if err := tx.Count(&total).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
|
|||||||
@@ -123,6 +123,66 @@ func TestFulfillmentCreateOrderDebitsWalletAndIsIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFulfillmentCreateManualOrderDebitsWalletAndStock(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t)
|
||||||
|
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-manual-order", 1000, 5, 200)
|
||||||
|
svc := NewFulfillmentService(db, nil)
|
||||||
|
|
||||||
|
created, err := svc.CreateManualOrder(CreateManualOrderInput{
|
||||||
|
MerchantID: merchantID,
|
||||||
|
ActorUserID: 42,
|
||||||
|
ClientOrderNo: "manual-001",
|
||||||
|
SKU: product.SKU,
|
||||||
|
Quantity: 2,
|
||||||
|
BuyerReference: "manual-buyer",
|
||||||
|
RequestData: map[string]string{"game_account": "123456"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create manual order: %v", err)
|
||||||
|
}
|
||||||
|
if created.Idempotent || created.Order.OrderSource != model.OrderSourceManual || created.Order.Amount != 400 {
|
||||||
|
t.Fatalf("unexpected manual order: %+v", created)
|
||||||
|
}
|
||||||
|
|
||||||
|
again, err := svc.CreateManualOrder(CreateManualOrderInput{
|
||||||
|
MerchantID: merchantID,
|
||||||
|
ActorUserID: 42,
|
||||||
|
ClientOrderNo: "manual-001",
|
||||||
|
SKU: product.SKU,
|
||||||
|
Quantity: 2,
|
||||||
|
BuyerReference: "manual-buyer",
|
||||||
|
RequestData: map[string]string{"game_account": "123456"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("idempotent manual order: %v", err)
|
||||||
|
}
|
||||||
|
if !again.Idempotent || again.Order.OrderNo != created.Order.OrderNo {
|
||||||
|
t.Fatalf("manual order should be idempotent, got %+v", again)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wallet model.WalletAccount
|
||||||
|
if err := db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil {
|
||||||
|
t.Fatalf("query wallet: %v", err)
|
||||||
|
}
|
||||||
|
if wallet.AvailableBalance != 600 {
|
||||||
|
t.Fatalf("wallet should debit once, got %d", wallet.AvailableBalance)
|
||||||
|
}
|
||||||
|
var refreshed model.MerchantProduct
|
||||||
|
if err := db.First(&refreshed, product.ID).Error; err != nil {
|
||||||
|
t.Fatalf("query product: %v", err)
|
||||||
|
}
|
||||||
|
if refreshed.Stock != 3 {
|
||||||
|
t.Fatalf("stock should decrease once, got %d", refreshed.Stock)
|
||||||
|
}
|
||||||
|
var audit model.AuditLog
|
||||||
|
if err := db.Where("entity_id = ? AND action = ?", created.Order.OrderNo, "merchant_order.manual_create").First(&audit).Error; err != nil {
|
||||||
|
t.Fatalf("query manual creation audit: %v", err)
|
||||||
|
}
|
||||||
|
if audit.ActorUserID == nil || *audit.ActorUserID != 42 || audit.APIClientID != nil {
|
||||||
|
t.Fatalf("unexpected manual creation audit: %+v", audit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFulfillmentCreateOrderRejectsIdempotencyMismatch(t *testing.T) {
|
func TestFulfillmentCreateOrderRejectsIdempotencyMismatch(t *testing.T) {
|
||||||
db := newServiceTestDB(t)
|
db := newServiceTestDB(t)
|
||||||
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-idempotency-mismatch", 1000, 5, 200)
|
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-idempotency-mismatch", 1000, 5, 200)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
ApiCredential,
|
ApiCredential,
|
||||||
CallbackCredential,
|
CallbackCredential,
|
||||||
CallbackSubscription,
|
CallbackSubscription,
|
||||||
|
CreateManualOrderResult,
|
||||||
CreateTestOrderResult,
|
CreateTestOrderResult,
|
||||||
DeliveryBindResult,
|
DeliveryBindResult,
|
||||||
DeliveryOrderInfo,
|
DeliveryOrderInfo,
|
||||||
@@ -66,6 +67,14 @@ export const merchantApi = {
|
|||||||
note?: string
|
note?: string
|
||||||
order_status?: 'paid' | 'ship_failed' | 'cancelled'
|
order_status?: 'paid' | 'ship_failed' | 'cancelled'
|
||||||
}) => request.post('/merchant/orders/test', data).then((r) => r.data.data as CreateTestOrderResult),
|
}) => request.post('/merchant/orders/test', data).then((r) => r.data.data as CreateTestOrderResult),
|
||||||
|
createManualOrder: (data: {
|
||||||
|
client_order_no: string
|
||||||
|
sku: string
|
||||||
|
quantity: number
|
||||||
|
buyer_reference?: string
|
||||||
|
game_account?: string
|
||||||
|
note?: string
|
||||||
|
}) => request.post('/merchant/orders/manual', data).then((r) => r.data.data as CreateManualOrderResult),
|
||||||
getDeliveryLink: (orderNo: string) =>
|
getDeliveryLink: (orderNo: string) =>
|
||||||
request.get(`/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link`).then((r) => r.data.data as DeliveryLinkResult),
|
request.get(`/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link`).then((r) => r.data.data as DeliveryLinkResult),
|
||||||
revokeDeliveryLink: (orderNo: string) =>
|
revokeDeliveryLink: (orderNo: string) =>
|
||||||
|
|||||||
@@ -2,33 +2,24 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
|
||||||
Tabs,
|
Tabs,
|
||||||
Tag,
|
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import {
|
import { ReloadOutlined } from '@ant-design/icons'
|
||||||
CopyOutlined,
|
|
||||||
LinkOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
} from '@ant-design/icons'
|
|
||||||
import { useAuth } from '../store/auth'
|
import { useAuth } from '../store/auth'
|
||||||
import { merchantApi } from '../api'
|
import { merchantApi } from '../api'
|
||||||
import { formatDateTime } from '../utils/time'
|
|
||||||
import { PageHeader } from '../components/PageHeader'
|
import { PageHeader } from '../components/PageHeader'
|
||||||
import type {
|
import type {
|
||||||
ApiClient,
|
ApiClient,
|
||||||
ApiCredential,
|
ApiCredential,
|
||||||
CallbackCredential,
|
CallbackCredential,
|
||||||
CallbackSubscription,
|
CallbackSubscription,
|
||||||
CreateTestOrderResult,
|
|
||||||
FulfillmentOrder,
|
FulfillmentOrder,
|
||||||
Merchant,
|
Merchant,
|
||||||
MerchantMember,
|
MerchantMember,
|
||||||
@@ -48,7 +39,6 @@ import {
|
|||||||
roleText,
|
roleText,
|
||||||
scopeOptions,
|
scopeOptions,
|
||||||
tabFromSearch,
|
tabFromSearch,
|
||||||
testOrderStatusOptions,
|
|
||||||
} from './merchantCenterUtils'
|
} from './merchantCenterUtils'
|
||||||
import type { MerchantCenterProps, MerchantCenterTab } from './merchantCenterUtils'
|
import type { MerchantCenterProps, MerchantCenterTab } from './merchantCenterUtils'
|
||||||
import {
|
import {
|
||||||
@@ -71,6 +61,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
const [activeTab, setActiveTab] = useState<MerchantCenterTab>(routeTab ?? 'products')
|
const [activeTab, setActiveTab] = useState<MerchantCenterTab>(routeTab ?? 'products')
|
||||||
const [products, setProducts] = useState<PageResult<MerchantProduct>>({ list: [], total: 0, page: 1, size: 10 })
|
const [products, setProducts] = useState<PageResult<MerchantProduct>>({ list: [], total: 0, page: 1, size: 10 })
|
||||||
const [orders, setOrders] = useState<PageResult<FulfillmentOrder>>({ list: [], total: 0, page: 1, size: 10 })
|
const [orders, setOrders] = useState<PageResult<FulfillmentOrder>>({ list: [], total: 0, page: 1, size: 10 })
|
||||||
|
const [orderSource, setOrderSource] = useState<'api' | 'manual'>('api')
|
||||||
const [ledger, setLedger] = useState<PageResult<WalletLedgerEntry>>({ list: [], total: 0, page: 1, size: 10 })
|
const [ledger, setLedger] = useState<PageResult<WalletLedgerEntry>>({ list: [], total: 0, page: 1, size: 10 })
|
||||||
const [wallet, setWallet] = useState<WalletAccount | null>(null)
|
const [wallet, setWallet] = useState<WalletAccount | null>(null)
|
||||||
const [apiClients, setApiClients] = useState<ApiClient[]>([])
|
const [apiClients, setApiClients] = useState<ApiClient[]>([])
|
||||||
@@ -81,24 +72,24 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
const [apiCredential, setApiCredential] = useState<ApiCredential | null>(null)
|
const [apiCredential, setApiCredential] = useState<ApiCredential | null>(null)
|
||||||
const [callbackCredential, setCallbackCredential] = useState<CallbackCredential | null>(null)
|
const [callbackCredential, setCallbackCredential] = useState<CallbackCredential | null>(null)
|
||||||
const [memberOpen, setMemberOpen] = useState(false)
|
const [memberOpen, setMemberOpen] = useState(false)
|
||||||
const [testOrderOpen, setTestOrderOpen] = useState(false)
|
const [manualOrderOpen, setManualOrderOpen] = useState(false)
|
||||||
const [testOrderProducts, setTestOrderProducts] = useState<MerchantProduct[]>([])
|
const [manualOrderSubmitting, setManualOrderSubmitting] = useState(false)
|
||||||
const [testOrderProductsLoading, setTestOrderProductsLoading] = useState(false)
|
const [manualOrderProducts, setManualOrderProducts] = useState<MerchantProduct[]>([])
|
||||||
const [testOrderResult, setTestOrderResult] = useState<CreateTestOrderResult | null>(null)
|
const [manualOrderProductsLoading, setManualOrderProductsLoading] = useState(false)
|
||||||
const [walletFilterForm] = Form.useForm()
|
const [walletFilterForm] = Form.useForm()
|
||||||
const [apiClientForm] = Form.useForm()
|
const [apiClientForm] = Form.useForm()
|
||||||
const [callbackForm] = Form.useForm()
|
const [callbackForm] = Form.useForm()
|
||||||
const [memberForm] = Form.useForm()
|
const [memberForm] = Form.useForm()
|
||||||
const [testOrderForm] = Form.useForm()
|
const [manualOrderForm] = Form.useForm()
|
||||||
|
|
||||||
const canManage = merchantRole === 'owner' || merchantRole === 'operator'
|
const canManage = merchantRole === 'owner' || merchantRole === 'operator'
|
||||||
const canFinance = merchantRole === 'owner' || merchantRole === 'finance'
|
const canFinance = merchantRole === 'owner' || merchantRole === 'finance'
|
||||||
const enabledFeatures = useMemo(() => new Set(featuresToList(merchant?.features)), [merchant?.features])
|
const enabledFeatures = useMemo(() => new Set(featuresToList(merchant?.features)), [merchant?.features])
|
||||||
const hasFeature = useCallback((feature: string) => !merchant || enabledFeatures.has(feature), [enabledFeatures, merchant])
|
const hasFeature = useCallback((feature: string) => !merchant || enabledFeatures.has(feature), [enabledFeatures, merchant])
|
||||||
const testOrderProductOptions = useMemo(() => testOrderProducts.map((item) => ({
|
const manualOrderProductOptions = useMemo(() => manualOrderProducts.map((item) => ({
|
||||||
value: item.sku,
|
value: item.sku,
|
||||||
label: productOptionLabel(item),
|
label: productOptionLabel(item),
|
||||||
})), [testOrderProducts])
|
})), [manualOrderProducts])
|
||||||
|
|
||||||
const copyDeliveryLink = useCallback((orderNo: string) => {
|
const copyDeliveryLink = useCallback((orderNo: string) => {
|
||||||
merchantApi.getDeliveryLink(orderNo)
|
merchantApi.getDeliveryLink(orderNo)
|
||||||
@@ -117,10 +108,6 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
...prev,
|
...prev,
|
||||||
list: prev.list.map((item) => item.order_no === orderNo ? { ...item, ...patch } : item),
|
list: prev.list.map((item) => item.order_no === orderNo ? { ...item, ...patch } : item),
|
||||||
}))
|
}))
|
||||||
setTestOrderResult((prev) => prev?.order.order_no === orderNo ? {
|
|
||||||
...prev,
|
|
||||||
order: { ...prev.order, ...patch },
|
|
||||||
} : prev)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const revokeDeliveryLink = useCallback((orderNo: string) => {
|
const revokeDeliveryLink = useCallback((orderNo: string) => {
|
||||||
@@ -157,10 +144,10 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
setProducts(data)
|
setProducts(data)
|
||||||
}, [products.page, products.size])
|
}, [products.page, products.size])
|
||||||
|
|
||||||
const loadOrders = useCallback(async (page = orders.page, size = orders.size) => {
|
const loadOrders = useCallback(async (page = orders.page, size = orders.size, source = orderSource) => {
|
||||||
const data = await merchantApi.orders({ page, size })
|
const data = await merchantApi.orders({ page, size, order_source: source })
|
||||||
setOrders(data)
|
setOrders(data)
|
||||||
}, [orders.page, orders.size])
|
}, [orderSource, orders.page, orders.size])
|
||||||
|
|
||||||
const loadWallet = useCallback(async (
|
const loadWallet = useCallback(async (
|
||||||
page = ledger.page,
|
page = ledger.page,
|
||||||
@@ -286,21 +273,25 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
})
|
})
|
||||||
}, [loadWallet, merchantRole, walletFilterForm])
|
}, [loadWallet, merchantRole, walletFilterForm])
|
||||||
|
|
||||||
const openTestOrderCreate = () => {
|
const newManualClientOrderNo = () => {
|
||||||
testOrderForm.resetFields()
|
const suffix = window.crypto?.randomUUID?.().replaceAll('-', '') || `${Date.now()}${Math.random().toString(36).slice(2)}`
|
||||||
testOrderForm.setFieldsValue({
|
return `manual-${suffix.slice(0, 32)}`
|
||||||
buyer_reference: '测试买家',
|
}
|
||||||
note: '联调测试订单',
|
|
||||||
order_status: 'paid',
|
const openManualOrderCreate = () => {
|
||||||
|
manualOrderForm.resetFields()
|
||||||
|
manualOrderForm.setFieldsValue({
|
||||||
|
client_order_no: newManualClientOrderNo(),
|
||||||
|
quantity: 1,
|
||||||
})
|
})
|
||||||
setTestOrderOpen(true)
|
setManualOrderOpen(true)
|
||||||
setTestOrderProductsLoading(true)
|
setManualOrderProductsLoading(true)
|
||||||
merchantApi.products({ page: 1, size: 100 })
|
merchantApi.products({ page: 1, size: 100 })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const activeProducts = (data.list || []).filter((item) => item.status === 'active')
|
const activeProducts = (data.list || []).filter((item) => item.status === 'active')
|
||||||
setTestOrderProducts(activeProducts)
|
setManualOrderProducts(activeProducts)
|
||||||
if (activeProducts.length > 0) {
|
if (activeProducts.length > 0) {
|
||||||
testOrderForm.setFieldsValue({ sku: activeProducts[0].sku })
|
manualOrderForm.setFieldsValue({ sku: activeProducts[0].sku })
|
||||||
} else {
|
} else {
|
||||||
message.warning('没有可用商品')
|
message.warning('没有可用商品')
|
||||||
}
|
}
|
||||||
@@ -308,27 +299,37 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
message.error(e instanceof Error ? e.message : '商品加载失败')
|
message.error(e instanceof Error ? e.message : '商品加载失败')
|
||||||
})
|
})
|
||||||
.finally(() => setTestOrderProductsLoading(false))
|
.finally(() => setManualOrderProductsLoading(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitTestOrder = async () => {
|
const submitManualOrder = async () => {
|
||||||
const values = await testOrderForm.validateFields()
|
const values = await manualOrderForm.validateFields()
|
||||||
|
setManualOrderSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const result = await merchantApi.createTestOrder({
|
const result = await merchantApi.createManualOrder({
|
||||||
|
client_order_no: values.client_order_no,
|
||||||
sku: values.sku,
|
sku: values.sku,
|
||||||
|
quantity: values.quantity,
|
||||||
buyer_reference: values.buyer_reference,
|
buyer_reference: values.buyer_reference,
|
||||||
|
game_account: values.game_account,
|
||||||
note: values.note,
|
note: values.note,
|
||||||
order_status: values.order_status,
|
|
||||||
})
|
})
|
||||||
setTestOrderOpen(false)
|
setManualOrderOpen(false)
|
||||||
setTestOrderResult(result)
|
setOrderSource('manual')
|
||||||
message.success('测试订单已创建')
|
message.success(result.idempotent ? '已找到已有手动订单,未重复扣款' : '手动订单已创建,余额与库存已扣减')
|
||||||
loadOrders()
|
await loadOrders(1, orders.size, 'manual')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '创建失败')
|
message.error(e instanceof Error ? e.message : '创建失败')
|
||||||
|
} finally {
|
||||||
|
setManualOrderSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const changeOrderSource = (source: 'api' | 'manual') => {
|
||||||
|
setOrderSource(source)
|
||||||
|
loadOrders(1, orders.size, source).catch((e) => message.error(e instanceof Error ? e.message : '订单加载失败'))
|
||||||
|
}
|
||||||
|
|
||||||
const submitAPIClient = async () => {
|
const submitAPIClient = async () => {
|
||||||
const values = await apiClientForm.validateFields()
|
const values = await apiClientForm.validateFields()
|
||||||
try {
|
try {
|
||||||
@@ -425,7 +426,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
<ProductsTab loading={loading} products={products} canManage={canManage} onToggleProduct={openProductToggle} onLoadProducts={loadProducts} />
|
<ProductsTab loading={loading} products={products} canManage={canManage} onToggleProduct={openProductToggle} onLoadProducts={loadProducts} />
|
||||||
) },
|
) },
|
||||||
{ key: 'orders', label: '发货订单', disabled: !hasFeature('orders'), children: (
|
{ key: 'orders', label: '发货订单', disabled: !hasFeature('orders'), children: (
|
||||||
<OrdersTab loading={loading} orders={orders} canManage={canManage} onCreateTestOrder={openTestOrderCreate} onLoadOrders={loadOrders} onCopyLink={copyDeliveryLink} onOpenLink={openDeliveryLink} onRevokeLink={revokeDeliveryLink} onRestoreLink={restoreDeliveryLink} />
|
<OrdersTab loading={loading} orders={orders} orderSource={orderSource} canManage={canManage} onCreateManualOrder={openManualOrderCreate} onOrderSourceChange={changeOrderSource} onLoadOrders={loadOrders} onCopyLink={copyDeliveryLink} onOpenLink={openDeliveryLink} onRevokeLink={revokeDeliveryLink} onRestoreLink={restoreDeliveryLink} />
|
||||||
) },
|
) },
|
||||||
{ key: 'wallet', label: '钱包', disabled: !hasFeature('wallet'), children: (
|
{ key: 'wallet', label: '钱包', disabled: !hasFeature('wallet'), children: (
|
||||||
<WalletTab loading={loading} wallet={wallet} canFinance={canFinance} ledger={ledger} form={walletFilterForm} onFilter={handleWalletFilter} onResetFilter={handleWalletFilterReset} onLoadLedger={handleLoadLedger} />
|
<WalletTab loading={loading} wallet={wallet} canFinance={canFinance} ledger={ledger} form={walletFilterForm} onFilter={handleWalletFilter} onResetFilter={handleWalletFilterReset} onLoadLedger={handleLoadLedger} />
|
||||||
@@ -483,21 +484,27 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal title="创建测试订单" open={testOrderOpen} onOk={submitTestOrder} onCancel={() => setTestOrderOpen(false)} destroyOnClose width={560}>
|
<Modal title="创建手动订单" open={manualOrderOpen} confirmLoading={manualOrderSubmitting} onOk={submitManualOrder} onCancel={() => setManualOrderOpen(false)} destroyOnClose width={560}>
|
||||||
<Form form={testOrderForm} layout="vertical" style={{ marginTop: 16 }}>
|
<Form form={manualOrderForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
<Form.Item name="sku" label="商品皮肤" rules={[{ required: true, message: '请选择商品' }]}>
|
<Form.Item name="client_order_no" label="商户单号" rules={[{ required: true, message: '请输入商户单号' }, { max: 96, message: '商户单号最长 96 个字符' }]}>
|
||||||
|
<Input maxLength={96} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="sku" label="商品" rules={[{ required: true, message: '请选择商品' }]}>
|
||||||
<Select
|
<Select
|
||||||
showSearch
|
showSearch
|
||||||
loading={testOrderProductsLoading}
|
loading={manualOrderProductsLoading}
|
||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
options={testOrderProductOptions}
|
options={manualOrderProductOptions}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="buyer_reference" label="买家名称" rules={[{ max: 128 }]}>
|
<Form.Item name="quantity" label="数量" rules={[{ required: true, message: '请输入数量' }]}>
|
||||||
<Input />
|
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="order_status" label="订单状态" rules={[{ required: true }]}>
|
<Form.Item name="game_account" label="充值账号 / 游戏 UID" rules={[{ max: 128, message: '账号最长 128 个字符' }]}>
|
||||||
<Select options={testOrderStatusOptions} />
|
<Input maxLength={128} placeholder="用于后续发货时预填" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="buyer_reference" label="买家标识" rules={[{ max: 128, message: '买家标识最长 128 个字符' }]}>
|
||||||
|
<Input maxLength={128} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="note" label="备注" rules={[{ max: 512 }]}>
|
<Form.Item name="note" label="备注" rules={[{ max: 512 }]}>
|
||||||
<Input.TextArea rows={3} />
|
<Input.TextArea rows={3} />
|
||||||
@@ -505,60 +512,6 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
|
||||||
title="测试订单"
|
|
||||||
open={!!testOrderResult}
|
|
||||||
onCancel={() => setTestOrderResult(null)}
|
|
||||||
footer={(
|
|
||||||
<Space>
|
|
||||||
<Button onClick={() => setTestOrderResult(null)}>关闭</Button>
|
|
||||||
{testOrderResult?.order.delivery_link_revoked_at ? (
|
|
||||||
<Button onClick={() => restoreDeliveryLink(testOrderResult?.order.order_no || '')}>取消作废</Button>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={() => copyDeliveryLink(testOrderResult?.order.order_no || '')}
|
|
||||||
>
|
|
||||||
复制发货链接
|
|
||||||
</Button>
|
|
||||||
<Button type="primary" icon={<LinkOutlined />} onClick={() => openDeliveryLink(testOrderResult?.order.order_no || '')}>
|
|
||||||
打开发货页
|
|
||||||
</Button>
|
|
||||||
<Button danger onClick={() => revokeDeliveryLink(testOrderResult?.order.order_no || '')}>作废链接</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{testOrderResult && (
|
|
||||||
<Descriptions size="small" bordered column={1} style={{ marginTop: 16 }}>
|
|
||||||
<Descriptions.Item label="订单号">
|
|
||||||
<Typography.Text copyable>{testOrderResult.order.order_no}</Typography.Text>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="SKU">
|
|
||||||
<Typography.Text code>{testOrderResult.order.product_sku}</Typography.Text>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="可发货">
|
|
||||||
<Tag color={testOrderResult.can_ship ? 'green' : 'red'}>
|
|
||||||
{testOrderResult.can_ship ? 'can_ship=true' : 'can_ship=false'}
|
|
||||||
</Tag>
|
|
||||||
</Descriptions.Item>
|
|
||||||
{!testOrderResult.can_ship && (
|
|
||||||
<Descriptions.Item label="不可发货原因">
|
|
||||||
<Typography.Text type="secondary">{testOrderResult.cannot_ship_reason || '-'}</Typography.Text>
|
|
||||||
</Descriptions.Item>
|
|
||||||
)}
|
|
||||||
<Descriptions.Item label="链接有效期">
|
|
||||||
{testOrderResult.order.delivery_link_revoked_at ? <Tag color="red">已作废</Tag> : formatDateTime(testOrderResult.order.delivery_link_expires_at)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="链接状态">
|
|
||||||
<Typography.Text type="secondary">由后端签名生成,可复制、打开或作废。</Typography.Text>
|
|
||||||
</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal title="新增 API 密钥" open={apiClientOpen} onOk={submitAPIClient} onCancel={() => setApiClientOpen(false)} destroyOnClose>
|
<Modal title="新增 API 密钥" open={apiClientOpen} onOk={submitAPIClient} onCancel={() => setApiClientOpen(false)} destroyOnClose>
|
||||||
<Form form={apiClientForm} layout="vertical" style={{ marginTop: 16 }}>
|
<Form form={apiClientForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Button, Card, Col, Form, Input, Popconfirm, Row, Select, Space, Table, Tag, Typography, message } from 'antd'
|
import { Button, Card, Col, Form, Input, Popconfirm, Row, Select, Space, Table, Tabs, Tag, Typography, message } from 'antd'
|
||||||
import type { FormInstance } from 'antd'
|
import type { FormInstance } from 'antd'
|
||||||
import {
|
import {
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
@@ -168,8 +168,10 @@ export function ProductsTab({
|
|||||||
export function OrdersTab({
|
export function OrdersTab({
|
||||||
loading,
|
loading,
|
||||||
orders,
|
orders,
|
||||||
|
orderSource,
|
||||||
canManage,
|
canManage,
|
||||||
onCreateTestOrder,
|
onCreateManualOrder,
|
||||||
|
onOrderSourceChange,
|
||||||
onLoadOrders,
|
onLoadOrders,
|
||||||
onCopyLink,
|
onCopyLink,
|
||||||
onOpenLink,
|
onOpenLink,
|
||||||
@@ -178,8 +180,10 @@ export function OrdersTab({
|
|||||||
}: {
|
}: {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
orders: PageResult<FulfillmentOrder>
|
orders: PageResult<FulfillmentOrder>
|
||||||
|
orderSource: 'api' | 'manual'
|
||||||
canManage: boolean
|
canManage: boolean
|
||||||
onCreateTestOrder: () => void
|
onCreateManualOrder: () => void
|
||||||
|
onOrderSourceChange: (source: 'api' | 'manual') => void
|
||||||
onLoadOrders: (page?: number, size?: number) => void
|
onLoadOrders: (page?: number, size?: number) => void
|
||||||
onCopyLink: (orderNo: string) => void
|
onCopyLink: (orderNo: string) => void
|
||||||
onOpenLink: (orderNo: string) => void
|
onOpenLink: (orderNo: string) => void
|
||||||
@@ -232,12 +236,17 @@ export function OrdersTab({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
<Space wrap style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
<Typography.Text type="secondary">发货链接由后端生成并带签名,用户只填写游戏 UID。</Typography.Text>
|
<Tabs
|
||||||
|
activeKey={orderSource}
|
||||||
|
onChange={(key) => onOrderSourceChange(key as 'api' | 'manual')}
|
||||||
|
items={[{ key: 'api', label: 'API 订单' }, { key: 'manual', label: '手动订单' }]}
|
||||||
|
style={{ minWidth: 220 }}
|
||||||
|
/>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={onCreateTestOrder}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={onCreateManualOrder}>
|
||||||
创建测试订单
|
创建手动订单
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export interface FulfillmentOrder {
|
|||||||
amount: number
|
amount: number
|
||||||
currency: string
|
currency: string
|
||||||
order_status: string
|
order_status: string
|
||||||
|
order_source: 'api' | 'manual' | 'test'
|
||||||
buyer_reference: string
|
buyer_reference: string
|
||||||
provider_order_no: string
|
provider_order_no: string
|
||||||
failure_reason: string
|
failure_reason: string
|
||||||
@@ -125,6 +126,13 @@ export interface CreateTestOrderResult {
|
|||||||
cannot_ship_reason?: string
|
cannot_ship_reason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateManualOrderResult {
|
||||||
|
order: FulfillmentOrder
|
||||||
|
idempotent: boolean
|
||||||
|
can_ship: boolean
|
||||||
|
cannot_ship_reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeliveryProduct {
|
export interface DeliveryProduct {
|
||||||
name: string
|
name: string
|
||||||
sku: string
|
sku: string
|
||||||
|
|||||||
Reference in New Issue
Block a user