恢复测试订单创建
This commit is contained in:
@@ -132,6 +132,39 @@ func (h *MerchantHandler) ListOrders(c *gin.Context) {
|
|||||||
response.Page(c, list, total, page, size)
|
response.Page(c, list, total, page, size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type merchantTestOrderReq struct {
|
||||||
|
SKU string `json:"sku" binding:"required"`
|
||||||
|
BuyerReference string `json:"buyer_reference"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
FulfillmentStatus string `json:"fulfillment_status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MerchantHandler) CreateTestOrder(c *gin.Context) {
|
||||||
|
var req merchantTestOrderReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "参数错误:sku 必填")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
order, err := h.fulfillmentSvc.CreateTestOrder(service.CreateTestOrderInput{
|
||||||
|
MerchantID: middleware.GetMerchantID(c),
|
||||||
|
ActorUserID: middleware.GetUserID(c),
|
||||||
|
SKU: req.SKU,
|
||||||
|
BuyerReference: req.BuyerReference,
|
||||||
|
Note: req.Note,
|
||||||
|
FulfillmentStatus: req.FulfillmentStatus,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canShip, cannotShipReason := service.CanFulfill(order)
|
||||||
|
response.OK(c, gin.H{
|
||||||
|
"order": order,
|
||||||
|
"can_ship": canShip,
|
||||||
|
"cannot_ship_reason": cannotShipReason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *MerchantHandler) GetWallet(c *gin.Context) {
|
func (h *MerchantHandler) GetWallet(c *gin.Context) {
|
||||||
wallet, err := h.fulfillmentSvc.GetWallet(middleware.GetMerchantID(c))
|
wallet, err := h.fulfillmentSvc.GetWallet(middleware.GetMerchantID(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -83,10 +83,10 @@ func Setup(h *Handlers) *gin.Engine {
|
|||||||
auth.Use(middleware.Auth(h.JWT))
|
auth.Use(middleware.Auth(h.JWT))
|
||||||
auth.Use(middleware.Tenant(h.Tenant))
|
auth.Use(middleware.Tenant(h.Tenant))
|
||||||
{
|
{
|
||||||
auth.GET("/auth/profile", h.Auth.Profile)
|
auth.GET("/auth/profile", h.Auth.Profile)
|
||||||
auth.GET("/dashboard", h.Dashboard.Dashboard)
|
auth.GET("/dashboard", h.Dashboard.Dashboard)
|
||||||
|
|
||||||
// 商户后台:商户就是平台下游客户,成员只代表该商户内部员工。
|
// 商户后台:商户就是平台下游客户,成员只代表该商户内部员工。
|
||||||
merchant := auth.Group("/merchant")
|
merchant := auth.Group("/merchant")
|
||||||
{
|
{
|
||||||
merchant.GET("", h.Merchant.Current)
|
merchant.GET("", h.Merchant.Current)
|
||||||
@@ -94,6 +94,7 @@ func Setup(h *Handlers) *gin.Engine {
|
|||||||
merchant.POST("/products", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateProduct)
|
merchant.POST("/products", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateProduct)
|
||||||
merchant.PATCH("/products/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateProduct)
|
merchant.PATCH("/products/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateProduct)
|
||||||
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/test", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateTestOrder)
|
||||||
merchant.GET("/wallet", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator, model.MemberRoleFinance), h.Merchant.GetWallet)
|
merchant.GET("/wallet", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator, model.MemberRoleFinance), h.Merchant.GetWallet)
|
||||||
merchant.GET("/wallet/ledger", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Merchant.ListWalletLedger)
|
merchant.GET("/wallet/ledger", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Merchant.ListWalletLedger)
|
||||||
merchant.POST("/wallet/adjust", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Merchant.AdjustWallet)
|
merchant.POST("/wallet/adjust", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Merchant.AdjustWallet)
|
||||||
@@ -111,16 +112,16 @@ func Setup(h *Handlers) *gin.Engine {
|
|||||||
admin := auth.Group("")
|
admin := auth.Group("")
|
||||||
admin.Use(middleware.RequireRole(model.RoleAdmin))
|
admin.Use(middleware.RequireRole(model.RoleAdmin))
|
||||||
{
|
{
|
||||||
admin.GET("/users", h.User.List)
|
admin.GET("/users", h.User.List)
|
||||||
admin.POST("/users", h.User.Create)
|
admin.POST("/users", h.User.Create)
|
||||||
admin.PATCH("/users/:id/status", h.User.UpdateStatus)
|
admin.PATCH("/users/:id/status", h.User.UpdateStatus)
|
||||||
admin.GET("/platform/merchants", h.Merchant.ListPlatformMerchants)
|
admin.GET("/platform/merchants", h.Merchant.ListPlatformMerchants)
|
||||||
admin.POST("/platform/merchants", h.Merchant.CreateMerchant)
|
admin.POST("/platform/merchants", h.Merchant.CreateMerchant)
|
||||||
admin.PATCH("/platform/merchants/:id", h.Merchant.UpdateMerchantSettings)
|
admin.PATCH("/platform/merchants/:id", h.Merchant.UpdateMerchantSettings)
|
||||||
admin.POST("/platform/merchants/:id/members", h.Merchant.AddPlatformMerchantMember)
|
admin.POST("/platform/merchants/:id/members", h.Merchant.AddPlatformMerchantMember)
|
||||||
admin.GET("/platform/product-catalog", h.Merchant.ListProductCatalog)
|
admin.GET("/platform/product-catalog", h.Merchant.ListProductCatalog)
|
||||||
admin.GET("/platform/merchants/:id/products", h.Merchant.ListMerchantProductsByAdmin)
|
admin.GET("/platform/merchants/:id/products", h.Merchant.ListMerchantProductsByAdmin)
|
||||||
admin.POST("/platform/merchants/:id/products/assign", h.Merchant.AssignProducts)
|
admin.POST("/platform/merchants/:id/products/assign", h.Merchant.AssignProducts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ type CreateFulfillmentOrderResult struct {
|
|||||||
Idempotent bool `json:"idempotent"`
|
Idempotent bool `json:"idempotent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateTestOrderInput struct {
|
||||||
|
MerchantID uint
|
||||||
|
ActorUserID uint
|
||||||
|
SKU string
|
||||||
|
BuyerReference string
|
||||||
|
Note string
|
||||||
|
FulfillmentStatus string
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -203,6 +212,113 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateTestOrder 创建后台联调订单:不扣钱包、不占库存,只用于上游按订单号查询与发货回传。
|
||||||
|
func (s *FulfillmentService) CreateTestOrder(in CreateTestOrderInput) (*model.FulfillmentOrder, error) {
|
||||||
|
in.SKU = strings.TrimSpace(in.SKU)
|
||||||
|
in.BuyerReference = strings.TrimSpace(in.BuyerReference)
|
||||||
|
in.Note = strings.TrimSpace(in.Note)
|
||||||
|
if in.MerchantID == 0 {
|
||||||
|
return nil, errors.New("无效的商户")
|
||||||
|
}
|
||||||
|
if in.SKU == "" {
|
||||||
|
return nil, errors.New("sku 不能为空")
|
||||||
|
}
|
||||||
|
if in.BuyerReference == "" {
|
||||||
|
in.BuyerReference = "测试买家"
|
||||||
|
}
|
||||||
|
if len(in.BuyerReference) > 128 {
|
||||||
|
return nil, errors.New("买家标识最长 128 位")
|
||||||
|
}
|
||||||
|
if len(in.Note) > 512 {
|
||||||
|
return nil, errors.New("备注最长 512 位")
|
||||||
|
}
|
||||||
|
if in.FulfillmentStatus == "" {
|
||||||
|
in.FulfillmentStatus = model.FulfillmentStatusPending
|
||||||
|
}
|
||||||
|
switch in.FulfillmentStatus {
|
||||||
|
case model.FulfillmentStatusPending, model.FulfillmentStatusFailed:
|
||||||
|
default:
|
||||||
|
return nil, errors.New("测试订单仅支持待履约或发货失败状态")
|
||||||
|
}
|
||||||
|
|
||||||
|
requestData := map[string]interface{}{
|
||||||
|
"source": "merchant_test_order",
|
||||||
|
}
|
||||||
|
if in.Note != "" {
|
||||||
|
requestData["note"] = in.Note
|
||||||
|
}
|
||||||
|
rawRequestData, err := json.Marshal(requestData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("订单请求数据无法序列化")
|
||||||
|
}
|
||||||
|
|
||||||
|
var out model.FulfillmentOrder
|
||||||
|
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var merchant model.Merchant
|
||||||
|
if err := tx.Where("id = ? AND status = ?", in.MerchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return errors.New("商户不存在或已禁用")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var product model.MerchantProduct
|
||||||
|
if err := tx.Preload("Product").
|
||||||
|
Where("merchant_id = ? AND sku = ? AND status = ?", in.MerchantID, in.SKU, model.ProductStatusActive).
|
||||||
|
First(&product).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return errors.New("商品不存在或已下架")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if product.Product == nil || product.Product.Status != model.ProductStatusActive {
|
||||||
|
return errors.New("商品目录已下架")
|
||||||
|
}
|
||||||
|
|
||||||
|
orderNo := newTestFulfillmentOrderNo()
|
||||||
|
order := &model.FulfillmentOrder{
|
||||||
|
MerchantID: in.MerchantID,
|
||||||
|
OrderNo: orderNo,
|
||||||
|
ClientOrderNo: "TEST-" + orderNo,
|
||||||
|
MerchantProductID: product.ID,
|
||||||
|
ProductSKU: product.SKU,
|
||||||
|
ProductName: fallbackName(product.DisplayName, product.Product.Name),
|
||||||
|
Quantity: 1,
|
||||||
|
BaseAmount: 0,
|
||||||
|
FeeType: merchant.FeeType,
|
||||||
|
FeeRateBP: merchant.FeeRateBP,
|
||||||
|
FeeFixedAmount: merchant.FeeFixedAmount,
|
||||||
|
ServiceFeeAmount: 0,
|
||||||
|
Amount: 0,
|
||||||
|
Currency: product.Currency,
|
||||||
|
PaymentStatus: model.PaymentStatusPaid,
|
||||||
|
FulfillmentStatus: in.FulfillmentStatus,
|
||||||
|
BuyerReference: in.BuyerReference,
|
||||||
|
RequestData: string(rawRequestData),
|
||||||
|
}
|
||||||
|
if in.FulfillmentStatus == model.FulfillmentStatusFailed {
|
||||||
|
order.FailureReason = fallbackName(in.Note, "联调测试订单初始化为发货失败,可重新发货")
|
||||||
|
}
|
||||||
|
if err := tx.Create(order).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeAudit(tx, &in.MerchantID, &in.ActorUserID, nil, "merchant_test_order.create", "fulfillment_order", order.OrderNo, map[string]interface{}{
|
||||||
|
"sku": in.SKU,
|
||||||
|
"fulfillment_status": in.FulfillmentStatus,
|
||||||
|
"can_ship": true,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = *order
|
||||||
|
out.MerchantProduct = &product
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *FulfillmentService) GetOrder(merchantID uint, orderNo string) (*model.FulfillmentOrder, error) {
|
func (s *FulfillmentService) GetOrder(merchantID uint, orderNo string) (*model.FulfillmentOrder, error) {
|
||||||
var order model.FulfillmentOrder
|
var order model.FulfillmentOrder
|
||||||
err := s.db.Preload("MerchantProduct.Product").
|
err := s.db.Preload("MerchantProduct.Product").
|
||||||
@@ -513,6 +629,10 @@ func newFulfillmentOrderNo() string {
|
|||||||
return "FO" + time.Now().UTC().Format("20060102150405") + strings.ReplaceAll(uuid.NewString()[:12], "-", "")
|
return "FO" + time.Now().UTC().Format("20060102150405") + strings.ReplaceAll(uuid.NewString()[:12], "-", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newTestFulfillmentOrderNo() string {
|
||||||
|
return "O" + time.Now().UTC().Format("20060102150405") + strings.ReplaceAll(uuid.NewString()[:12], "-", "")
|
||||||
|
}
|
||||||
|
|
||||||
// calculateServiceFee 按"百分比或固定"二选一计算手续费:
|
// calculateServiceFee 按"百分比或固定"二选一计算手续费:
|
||||||
// - feeType=rate:按 baseAmount * feeRateBP / 10000 计算
|
// - feeType=rate:按 baseAmount * feeRateBP / 10000 计算
|
||||||
// - feeType=fixed:直接取 feeFixedAmount
|
// - feeType=fixed:直接取 feeFixedAmount
|
||||||
@@ -567,7 +687,7 @@ func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} {
|
|||||||
"fee_type": order.FeeType,
|
"fee_type": order.FeeType,
|
||||||
"service_fee_amount": order.ServiceFeeAmount,
|
"service_fee_amount": order.ServiceFeeAmount,
|
||||||
"amount": order.Amount,
|
"amount": order.Amount,
|
||||||
"currency": order.Currency,
|
"currency": order.Currency,
|
||||||
"payment_status": order.PaymentStatus,
|
"payment_status": order.PaymentStatus,
|
||||||
"fulfillment_status": order.FulfillmentStatus,
|
"fulfillment_status": order.FulfillmentStatus,
|
||||||
"can_fulfill": canFulfill,
|
"can_fulfill": canFulfill,
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ func TestFulfillmentCreateOrderAppliesMerchantFeeRate(t *testing.T) {
|
|||||||
db := newServiceTestDB(t)
|
db := newServiceTestDB(t)
|
||||||
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-fee-rate", 1000, 5, 200)
|
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-fee-rate", 1000, 5, 200)
|
||||||
if err := db.Model(&model.Merchant{}).Where("id = ?", merchantID).Updates(map[string]interface{}{
|
if err := db.Model(&model.Merchant{}).Where("id = ?", merchantID).Updates(map[string]interface{}{
|
||||||
"fee_type": model.FeeTypeRate,
|
"fee_type": model.FeeTypeRate,
|
||||||
"fee_rate_bp": int64(250),
|
"fee_rate_bp": int64(250),
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
t.Fatalf("update merchant fee: %v", err)
|
t.Fatalf("update merchant fee: %v", err)
|
||||||
@@ -373,3 +373,85 @@ func TestCreateOrderRejectsInsufficientBalance(t *testing.T) {
|
|||||||
t.Fatalf("balance should remain unchanged, got %d", wallet.AvailableBalance)
|
t.Fatalf("balance should remain unchanged, got %d", wallet.AvailableBalance)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateTestOrderCreatesFulfillableOrderWithoutBilling(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t)
|
||||||
|
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-test-order", 0, 0, 100)
|
||||||
|
originalStock := product.Stock
|
||||||
|
svc := NewFulfillmentService(db, nil)
|
||||||
|
|
||||||
|
order, err := svc.CreateTestOrder(CreateTestOrderInput{
|
||||||
|
MerchantID: merchantID,
|
||||||
|
ActorUserID: 99,
|
||||||
|
SKU: product.SKU,
|
||||||
|
BuyerReference: "测试买家 A",
|
||||||
|
Note: "联调测试",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create test order: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(order.OrderNo, "O") || order.ClientOrderNo != "TEST-"+order.OrderNo {
|
||||||
|
t.Fatalf("unexpected test order numbers: %+v", order)
|
||||||
|
}
|
||||||
|
if order.PaymentStatus != model.PaymentStatusPaid || order.FulfillmentStatus != model.FulfillmentStatusPending {
|
||||||
|
t.Fatalf("unexpected test order status: %+v", order)
|
||||||
|
}
|
||||||
|
canShip, reason := CanFulfill(order)
|
||||||
|
if !canShip || reason != "" {
|
||||||
|
t.Fatalf("test order should be fulfillable, canShip=%v reason=%q", canShip, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
openOrder, err := svc.QueryOpenOrder(order.OrderNo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query open order: %v", err)
|
||||||
|
}
|
||||||
|
if !openOrder.CanShip || openOrder.Product == nil || openOrder.Product.SKU != product.SKU {
|
||||||
|
t.Fatalf("unexpected open order: %+v", openOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 != 0 {
|
||||||
|
t.Fatalf("test order should not debit wallet, 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 != originalStock {
|
||||||
|
t.Fatalf("test order should not decrease stock, got %d", refreshed.Stock)
|
||||||
|
}
|
||||||
|
var ledgerCount int64
|
||||||
|
db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID).Count(&ledgerCount)
|
||||||
|
if ledgerCount != 0 {
|
||||||
|
t.Fatalf("test order should not create wallet ledger, got %d", ledgerCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTestOrderSupportsFailedFulfillableStatus(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t)
|
||||||
|
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-test-failed", 0, -1, 100)
|
||||||
|
svc := NewFulfillmentService(db, nil)
|
||||||
|
|
||||||
|
order, err := svc.CreateTestOrder(CreateTestOrderInput{
|
||||||
|
MerchantID: merchantID,
|
||||||
|
SKU: product.SKU,
|
||||||
|
FulfillmentStatus: model.FulfillmentStatusFailed,
|
||||||
|
Note: "等待重新发货",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create failed test order: %v", err)
|
||||||
|
}
|
||||||
|
if order.FulfillmentStatus != model.FulfillmentStatusFailed || order.FailureReason != "等待重新发货" {
|
||||||
|
t.Fatalf("unexpected failed test order: %+v", order)
|
||||||
|
}
|
||||||
|
openOrder, err := svc.QueryOpenOrder(order.OrderNo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query open failed order: %v", err)
|
||||||
|
}
|
||||||
|
if !openOrder.CanShip || openOrder.Status != "ship_failed" {
|
||||||
|
t.Fatalf("failed test order should be re-fulfillable, got %+v", openOrder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
ApiCredential,
|
ApiCredential,
|
||||||
CallbackCredential,
|
CallbackCredential,
|
||||||
CallbackSubscription,
|
CallbackSubscription,
|
||||||
|
CreateTestOrderResult,
|
||||||
FulfillmentOrder,
|
FulfillmentOrder,
|
||||||
LoginResult,
|
LoginResult,
|
||||||
Merchant,
|
Merchant,
|
||||||
@@ -62,6 +63,12 @@ export const merchantApi = {
|
|||||||
request.patch(`/merchant/products/${id}`, data).then((r) => r.data.data),
|
request.patch(`/merchant/products/${id}`, data).then((r) => r.data.data),
|
||||||
orders: (params?: Record<string, unknown>) =>
|
orders: (params?: Record<string, unknown>) =>
|
||||||
request.get('/merchant/orders', { params }).then((r) => r.data.data as PageResult<FulfillmentOrder>),
|
request.get('/merchant/orders', { params }).then((r) => r.data.data as PageResult<FulfillmentOrder>),
|
||||||
|
createTestOrder: (data: {
|
||||||
|
sku: string
|
||||||
|
buyer_reference?: string
|
||||||
|
note?: string
|
||||||
|
fulfillment_status?: 'pending' | 'failed'
|
||||||
|
}) => request.post('/merchant/orders/test', data).then((r) => r.data.data as CreateTestOrderResult),
|
||||||
wallet: () =>
|
wallet: () =>
|
||||||
request.get('/merchant/wallet').then((r) => r.data.data as WalletAccount),
|
request.get('/merchant/wallet').then((r) => r.data.data as WalletAccount),
|
||||||
ledger: (params?: Record<string, unknown>) =>
|
ledger: (params?: Record<string, unknown>) =>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
|
LinkOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
WalletOutlined,
|
WalletOutlined,
|
||||||
@@ -29,6 +30,7 @@ import type {
|
|||||||
ApiCredential,
|
ApiCredential,
|
||||||
CallbackCredential,
|
CallbackCredential,
|
||||||
CallbackSubscription,
|
CallbackSubscription,
|
||||||
|
CreateTestOrderResult,
|
||||||
FulfillmentOrder,
|
FulfillmentOrder,
|
||||||
Merchant,
|
Merchant,
|
||||||
MerchantMember,
|
MerchantMember,
|
||||||
@@ -75,6 +77,13 @@ const eventOptions = [
|
|||||||
{ value: 'order.cancelled', label: '订单取消' },
|
{ value: 'order.cancelled', label: '订单取消' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const deliveryTestUrl = import.meta.env.VITE_DELIVERY_TEST_URL || 'https://www.jxya.top/test-delivery/dlc/'
|
||||||
|
|
||||||
|
const testOrderStatusOptions = [
|
||||||
|
{ value: 'pending', label: '已支付,可发货' },
|
||||||
|
{ value: 'failed', label: '发货失败,可重试' },
|
||||||
|
]
|
||||||
|
|
||||||
export default function MerchantCenter() {
|
export default function MerchantCenter() {
|
||||||
const [merchant, setMerchant] = useState<Merchant | null>(null)
|
const [merchant, setMerchant] = useState<Merchant | null>(null)
|
||||||
const [merchantRole, setMerchantRole] = useState<MerchantMember['role']>()
|
const [merchantRole, setMerchantRole] = useState<MerchantMember['role']>()
|
||||||
@@ -95,16 +104,25 @@ export default function MerchantCenter() {
|
|||||||
const [callbackOpen, setCallbackOpen] = useState(false)
|
const [callbackOpen, setCallbackOpen] = useState(false)
|
||||||
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 [testOrderProducts, setTestOrderProducts] = useState<MerchantProduct[]>([])
|
||||||
|
const [testOrderProductsLoading, setTestOrderProductsLoading] = useState(false)
|
||||||
|
const [testOrderResult, setTestOrderResult] = useState<CreateTestOrderResult | null>(null)
|
||||||
const [productForm] = Form.useForm()
|
const [productForm] = Form.useForm()
|
||||||
const [walletForm] = Form.useForm()
|
const [walletForm] = 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 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) => ({
|
||||||
|
value: item.sku,
|
||||||
|
label: productOptionLabel(item),
|
||||||
|
})), [testOrderProducts])
|
||||||
|
|
||||||
const loadCurrent = useCallback(async () => {
|
const loadCurrent = useCallback(async () => {
|
||||||
const data = await merchantApi.current()
|
const data = await merchantApi.current()
|
||||||
@@ -266,6 +284,49 @@ export default function MerchantCenter() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openTestOrderCreate = () => {
|
||||||
|
testOrderForm.resetFields()
|
||||||
|
testOrderForm.setFieldsValue({
|
||||||
|
buyer_reference: '测试买家',
|
||||||
|
note: '联调测试订单',
|
||||||
|
fulfillment_status: 'pending',
|
||||||
|
})
|
||||||
|
setTestOrderOpen(true)
|
||||||
|
setTestOrderProductsLoading(true)
|
||||||
|
merchantApi.products({ page: 1, size: 100 })
|
||||||
|
.then((data) => {
|
||||||
|
const activeProducts = (data.list || []).filter((item) => item.status === 'active')
|
||||||
|
setTestOrderProducts(activeProducts)
|
||||||
|
if (activeProducts.length > 0) {
|
||||||
|
testOrderForm.setFieldsValue({ sku: activeProducts[0].sku })
|
||||||
|
} else {
|
||||||
|
message.warning('没有可用商品')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
message.error(e instanceof Error ? e.message : '商品加载失败')
|
||||||
|
})
|
||||||
|
.finally(() => setTestOrderProductsLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitTestOrder = async () => {
|
||||||
|
const values = await testOrderForm.validateFields()
|
||||||
|
try {
|
||||||
|
const result = await merchantApi.createTestOrder({
|
||||||
|
sku: values.sku,
|
||||||
|
buyer_reference: values.buyer_reference,
|
||||||
|
note: values.note,
|
||||||
|
fulfillment_status: values.fulfillment_status,
|
||||||
|
})
|
||||||
|
setTestOrderOpen(false)
|
||||||
|
setTestOrderResult(result)
|
||||||
|
message.success('测试订单已创建')
|
||||||
|
loadOrders()
|
||||||
|
} 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 {
|
||||||
@@ -470,15 +531,30 @@ export default function MerchantCenter() {
|
|||||||
label: '履约订单',
|
label: '履约订单',
|
||||||
disabled: !hasFeature('orders'),
|
disabled: !hasFeature('orders'),
|
||||||
children: (
|
children: (
|
||||||
<Table
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
rowKey="id"
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
loading={loading}
|
<Typography.Text type="secondary">订单号用于发货平台查询。</Typography.Text>
|
||||||
columns={orderColumns}
|
{canManage && (
|
||||||
dataSource={orders.list}
|
<Space>
|
||||||
tableLayout="fixed"
|
<Button icon={<LinkOutlined />} href={deliveryTestUrl} target="_blank" rel="noreferrer">
|
||||||
scroll={{ x: 1300 }}
|
发货测试页
|
||||||
pagination={pageConfig(orders, loadOrders)}
|
</Button>
|
||||||
/>
|
<Button type="primary" icon={<PlusOutlined />} onClick={openTestOrderCreate}>
|
||||||
|
创建测试订单
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={orderColumns}
|
||||||
|
dataSource={orders.list}
|
||||||
|
tableLayout="fixed"
|
||||||
|
scroll={{ x: 1300 }}
|
||||||
|
pagination={pageConfig(orders, loadOrders)}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -627,6 +703,69 @@ export default function MerchantCenter() {
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal title="创建测试订单" open={testOrderOpen} onOk={submitTestOrder} onCancel={() => setTestOrderOpen(false)} destroyOnClose width={560}>
|
||||||
|
<Form form={testOrderForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item name="sku" label="商品皮肤" rules={[{ required: true, message: '请选择商品' }]}>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
loading={testOrderProductsLoading}
|
||||||
|
optionFilterProp="label"
|
||||||
|
options={testOrderProductOptions}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="buyer_reference" label="买家名称" rules={[{ max: 128 }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="fulfillment_status" label="可发货状态" rules={[{ required: true }]}>
|
||||||
|
<Select options={testOrderStatusOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="note" label="备注" rules={[{ max: 512 }]}>
|
||||||
|
<Input.TextArea rows={3} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="测试订单"
|
||||||
|
open={!!testOrderResult}
|
||||||
|
onCancel={() => setTestOrderResult(null)}
|
||||||
|
footer={(
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => setTestOrderResult(null)}>关闭</Button>
|
||||||
|
<Button
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => navigator.clipboard.writeText(testOrderResult?.order.order_no || '').then(() => message.success('已复制'))}
|
||||||
|
>
|
||||||
|
复制订单号
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<LinkOutlined />} href={deliveryTestUrl} target="_blank" rel="noreferrer">
|
||||||
|
发货测试页
|
||||||
|
</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>
|
||||||
|
<Descriptions.Item label="测试页">
|
||||||
|
<Typography.Link href={deliveryTestUrl} target="_blank" rel="noreferrer">
|
||||||
|
{deliveryTestUrl}
|
||||||
|
</Typography.Link>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal title="钱包调整" open={walletOpen} onOk={submitWalletAdjust} onCancel={() => setWalletOpen(false)} destroyOnClose>
|
<Modal title="钱包调整" open={walletOpen} onOk={submitWalletAdjust} onCancel={() => setWalletOpen(false)} destroyOnClose>
|
||||||
<Form form={walletForm} layout="vertical" style={{ marginTop: 16 }}>
|
<Form form={walletForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
<Form.Item name="amount" label="调整积分" rules={[{ required: true }]}>
|
<Form.Item name="amount" label="调整积分" rules={[{ required: true }]}>
|
||||||
@@ -775,6 +914,11 @@ function activeStatusTag(value: string) {
|
|||||||
return value === 'active' ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>
|
return value === 'active' ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function productOptionLabel(item: MerchantProduct) {
|
||||||
|
const name = item.display_name || item.product?.name || item.sku
|
||||||
|
return name === item.sku ? item.sku : `${name} / ${item.sku}`
|
||||||
|
}
|
||||||
|
|
||||||
function paymentStatusTag(value: string) {
|
function paymentStatusTag(value: string) {
|
||||||
const item = paymentStatusMap[value] || { color: 'default', text: value }
|
const item = paymentStatusMap[value] || { color: 'default', text: value }
|
||||||
return <Tag color={item.color}>{item.text}</Tag>
|
return <Tag color={item.color}>{item.text}</Tag>
|
||||||
|
|||||||
@@ -118,6 +118,12 @@ export interface FulfillmentOrder {
|
|||||||
cancelled_at?: string | null
|
cancelled_at?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateTestOrderResult {
|
||||||
|
order: FulfillmentOrder
|
||||||
|
can_ship: boolean
|
||||||
|
cannot_ship_reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiClient {
|
export interface ApiClient {
|
||||||
id: number
|
id: number
|
||||||
merchant_id: number
|
merchant_id: number
|
||||||
|
|||||||
Reference in New Issue
Block a user