From 254ba9b721b9a2ddc4c8d884a15655c61bd5620a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 30 Jul 2026 19:04:38 +0800 Subject: [PATCH] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=B5=8B=E8=AF=95=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E5=88=9B=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/merchant.go | 33 ++++ backend/internal/router/router.go | 27 ++-- backend/internal/service/fulfillment.go | 122 +++++++++++++- backend/internal/service/fulfillment_test.go | 84 +++++++++- frontend/src/api/index.ts | 7 + frontend/src/pages/MerchantCenter.tsx | 162 +++++++++++++++++-- frontend/src/types/index.ts | 6 + 7 files changed, 417 insertions(+), 24 deletions(-) diff --git a/backend/internal/handler/merchant.go b/backend/internal/handler/merchant.go index fbc183f..a49b481 100644 --- a/backend/internal/handler/merchant.go +++ b/backend/internal/handler/merchant.go @@ -132,6 +132,39 @@ func (h *MerchantHandler) ListOrders(c *gin.Context) { 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) { wallet, err := h.fulfillmentSvc.GetWallet(middleware.GetMerchantID(c)) if err != nil { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index d991870..2a99b02 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -83,10 +83,10 @@ func Setup(h *Handlers) *gin.Engine { auth.Use(middleware.Auth(h.JWT)) auth.Use(middleware.Tenant(h.Tenant)) { - auth.GET("/auth/profile", h.Auth.Profile) - auth.GET("/dashboard", h.Dashboard.Dashboard) + auth.GET("/auth/profile", h.Auth.Profile) + auth.GET("/dashboard", h.Dashboard.Dashboard) - // 商户后台:商户就是平台下游客户,成员只代表该商户内部员工。 + // 商户后台:商户就是平台下游客户,成员只代表该商户内部员工。 merchant := auth.Group("/merchant") { 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.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.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/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) @@ -111,16 +112,16 @@ func Setup(h *Handlers) *gin.Engine { admin := auth.Group("") admin.Use(middleware.RequireRole(model.RoleAdmin)) { - admin.GET("/users", h.User.List) - admin.POST("/users", h.User.Create) - admin.PATCH("/users/:id/status", h.User.UpdateStatus) - admin.GET("/platform/merchants", h.Merchant.ListPlatformMerchants) - admin.POST("/platform/merchants", h.Merchant.CreateMerchant) - admin.PATCH("/platform/merchants/:id", h.Merchant.UpdateMerchantSettings) - admin.POST("/platform/merchants/:id/members", h.Merchant.AddPlatformMerchantMember) - admin.GET("/platform/product-catalog", h.Merchant.ListProductCatalog) - admin.GET("/platform/merchants/:id/products", h.Merchant.ListMerchantProductsByAdmin) - admin.POST("/platform/merchants/:id/products/assign", h.Merchant.AssignProducts) + admin.GET("/users", h.User.List) + admin.POST("/users", h.User.Create) + admin.PATCH("/users/:id/status", h.User.UpdateStatus) + admin.GET("/platform/merchants", h.Merchant.ListPlatformMerchants) + admin.POST("/platform/merchants", h.Merchant.CreateMerchant) + admin.PATCH("/platform/merchants/:id", h.Merchant.UpdateMerchantSettings) + admin.POST("/platform/merchants/:id/members", h.Merchant.AddPlatformMerchantMember) + admin.GET("/platform/product-catalog", h.Merchant.ListProductCatalog) + admin.GET("/platform/merchants/:id/products", h.Merchant.ListMerchantProductsByAdmin) + admin.POST("/platform/merchants/:id/products/assign", h.Merchant.AssignProducts) } } } diff --git a/backend/internal/service/fulfillment.go b/backend/internal/service/fulfillment.go index 434b80b..0f25e35 100644 --- a/backend/internal/service/fulfillment.go +++ b/backend/internal/service/fulfillment.go @@ -39,6 +39,15 @@ type CreateFulfillmentOrderResult struct { 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) { in.ClientOrderNo = strings.TrimSpace(in.ClientOrderNo) in.SKU = strings.TrimSpace(in.SKU) @@ -203,6 +212,113 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat 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) { var order model.FulfillmentOrder 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], "-", "") } +func newTestFulfillmentOrderNo() string { + return "O" + time.Now().UTC().Format("20060102150405") + strings.ReplaceAll(uuid.NewString()[:12], "-", "") +} + // calculateServiceFee 按"百分比或固定"二选一计算手续费: // - feeType=rate:按 baseAmount * feeRateBP / 10000 计算 // - feeType=fixed:直接取 feeFixedAmount @@ -567,7 +687,7 @@ func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} { "fee_type": order.FeeType, "service_fee_amount": order.ServiceFeeAmount, "amount": order.Amount, - "currency": order.Currency, + "currency": order.Currency, "payment_status": order.PaymentStatus, "fulfillment_status": order.FulfillmentStatus, "can_fulfill": canFulfill, diff --git a/backend/internal/service/fulfillment_test.go b/backend/internal/service/fulfillment_test.go index a774337..24265c9 100644 --- a/backend/internal/service/fulfillment_test.go +++ b/backend/internal/service/fulfillment_test.go @@ -121,7 +121,7 @@ func TestFulfillmentCreateOrderAppliesMerchantFeeRate(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-fee-rate", 1000, 5, 200) 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), }).Error; err != nil { 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) } } + +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) + } +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 0d59f63..38207dd 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -5,6 +5,7 @@ import type { ApiCredential, CallbackCredential, CallbackSubscription, + CreateTestOrderResult, FulfillmentOrder, LoginResult, Merchant, @@ -62,6 +63,12 @@ export const merchantApi = { request.patch(`/merchant/products/${id}`, data).then((r) => r.data.data), orders: (params?: Record) => request.get('/merchant/orders', { params }).then((r) => r.data.data as PageResult), + 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: () => request.get('/merchant/wallet').then((r) => r.data.data as WalletAccount), ledger: (params?: Record) => diff --git a/frontend/src/pages/MerchantCenter.tsx b/frontend/src/pages/MerchantCenter.tsx index 27faf21..12a27f7 100644 --- a/frontend/src/pages/MerchantCenter.tsx +++ b/frontend/src/pages/MerchantCenter.tsx @@ -17,6 +17,7 @@ import { import { ApiOutlined, CopyOutlined, + LinkOutlined, PlusOutlined, ReloadOutlined, WalletOutlined, @@ -29,6 +30,7 @@ import type { ApiCredential, CallbackCredential, CallbackSubscription, + CreateTestOrderResult, FulfillmentOrder, Merchant, MerchantMember, @@ -75,6 +77,13 @@ const eventOptions = [ { 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() { const [merchant, setMerchant] = useState(null) const [merchantRole, setMerchantRole] = useState() @@ -95,16 +104,25 @@ export default function MerchantCenter() { const [callbackOpen, setCallbackOpen] = useState(false) const [callbackCredential, setCallbackCredential] = useState(null) const [memberOpen, setMemberOpen] = useState(false) + const [testOrderOpen, setTestOrderOpen] = useState(false) + const [testOrderProducts, setTestOrderProducts] = useState([]) + const [testOrderProductsLoading, setTestOrderProductsLoading] = useState(false) + const [testOrderResult, setTestOrderResult] = useState(null) const [productForm] = Form.useForm() const [walletForm] = Form.useForm() const [apiClientForm] = Form.useForm() const [callbackForm] = Form.useForm() const [memberForm] = Form.useForm() + const [testOrderForm] = Form.useForm() const canManage = merchantRole === 'owner' || merchantRole === 'operator' const canFinance = merchantRole === 'owner' || merchantRole === 'finance' const enabledFeatures = useMemo(() => new Set(featuresToList(merchant?.features)), [merchant?.features]) 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 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 values = await apiClientForm.validateFields() try { @@ -470,15 +531,30 @@ export default function MerchantCenter() { label: '履约订单', disabled: !hasFeature('orders'), children: ( - + + + 订单号用于发货平台查询。 + {canManage && ( + + + + + )} + +
+ ), }, { @@ -627,6 +703,69 @@ export default function MerchantCenter() { + setTestOrderOpen(false)} destroyOnClose width={560}> +
+ + + + +