From b686a82e60145937cf34407a261c9a45deb379bc Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 11 Aug 2026 11:49:58 +0800 Subject: [PATCH] feat: add manual fulfillment order creation --- .../006_fulfillment_order_source.sql | 6 + backend/internal/handler/merchant.go | 53 +++++- backend/internal/handler/open_v1.go | 1 + backend/internal/model/merchant.go | 5 + backend/internal/router/router.go | 1 + backend/internal/service/fulfillment.go | 54 +++++- backend/internal/service/fulfillment_query.go | 10 +- backend/internal/service/fulfillment_test.go | 60 +++++++ frontend/src/api/index.ts | 9 + frontend/src/pages/MerchantCenter.tsx | 169 +++++++----------- frontend/src/pages/merchantCenterTabs.tsx | 23 ++- frontend/src/types/index.ts | 8 + 12 files changed, 278 insertions(+), 121 deletions(-) create mode 100644 backend/internal/database/migrations/006_fulfillment_order_source.sql diff --git a/backend/internal/database/migrations/006_fulfillment_order_source.sql b/backend/internal/database/migrations/006_fulfillment_order_source.sql new file mode 100644 index 0000000..0ad76f2 --- /dev/null +++ b/backend/internal/database/migrations/006_fulfillment_order_source.sql @@ -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); diff --git a/backend/internal/handler/merchant.go b/backend/internal/handler/merchant.go index 200801c..afc41e9 100644 --- a/backend/internal/handler/merchant.go +++ b/backend/internal/handler/merchant.go @@ -2,6 +2,7 @@ package handler import ( "strconv" + "strings" "time" "affiliate_dash/internal/middleware" @@ -76,14 +77,64 @@ func (h *MerchantHandler) UpdateProductStatus(c *gin.Context) { func (h *MerchantHandler) ListOrders(c *gin.Context) { 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 orderSource != "" && err.Error() == "无效的订单来源" { + response.BadRequest(c, err.Error()) + return + } response.ServerError(c, err.Error()) return } 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 { SKU string `json:"sku" binding:"required"` BuyerReference string `json:"buyer_reference"` diff --git a/backend/internal/handler/open_v1.go b/backend/internal/handler/open_v1.go index 3942f6b..ce378b2 100644 --- a/backend/internal/handler/open_v1.go +++ b/backend/internal/handler/open_v1.go @@ -281,6 +281,7 @@ func buildOpenOrderResponse(order *model.FulfillmentOrder) gin.H { "order_no": order.OrderNo, "client_order_no": order.ClientOrderNo, "order_status": order.OrderStatus, + "order_source": order.OrderSource, "can_ship": canShip, "cannot_ship_reason": reason, "product": gin.H{ diff --git a/backend/internal/model/merchant.go b/backend/internal/model/merchant.go index 13d0f63..5cf3c45 100644 --- a/backend/internal/model/merchant.go +++ b/backend/internal/model/merchant.go @@ -40,6 +40,10 @@ const ( OrderStatusShipFailed = "ship_failed" // 发货失败(可重试) OrderStatusCancelled = "cancelled" // 已取消并退款 + OrderSourceAPI = "api" // 商户开放接口创建 + OrderSourceManual = "manual" // 商户后台人工创建 + OrderSourceTest = "test" // 后台联调测试订单 + WalletLedgerCredit = "credit" WalletLedgerDebit = "debit" WalletLedgerRefund = "refund" @@ -206,6 +210,7 @@ type FulfillmentOrder struct { Amount int64 `gorm:"not null" json:"amount"` Currency string `gorm:"size:12;not null;default:POINT" json:"currency"` 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"` RequestFingerprint string `gorm:"size:64;not null;default:'';index" json:"-"` RequestData string `gorm:"type:text" json:"request_data"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 9b92252..90f7545 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -155,6 +155,7 @@ func Setup(h *Handlers) *gin.Engine { 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.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.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) diff --git a/backend/internal/service/fulfillment.go b/backend/internal/service/fulfillment.go index 8f9bf60..0dac94b 100644 --- a/backend/internal/service/fulfillment.go +++ b/backend/internal/service/fulfillment.go @@ -26,11 +26,13 @@ func NewFulfillmentService(db *gorm.DB, callbacks *CallbackService) *Fulfillment type CreateFulfillmentOrderInput struct { MerchantID uint APIClientID uint + ActorUserID uint ClientOrderNo string SKU string Quantity int64 BuyerReference string RequestData interface{} + OrderSource string } type CreateFulfillmentOrderResult struct { @@ -47,11 +49,35 @@ type CreateTestOrderInput struct { 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) { in.ClientOrderNo = strings.TrimSpace(in.ClientOrderNo) in.SKU = strings.TrimSpace(in.SKU) - if in.MerchantID == 0 || in.APIClientID == 0 { - return nil, errors.New("无效的商户或 API 客户端") + if in.MerchantID == 0 || (in.APIClientID == 0 && in.ActorUserID == 0) { + return nil, errors.New("无效的商户或下单身份") } if in.ClientOrderNo == "" || len(in.ClientOrderNo) > 96 { return nil, errors.New("client_order_no 不能为空且最长 96 位") @@ -65,6 +91,15 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat if in.Quantity < 1 { 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 := "" if in.RequestData != nil { raw, err := json.Marshal(in.RequestData) @@ -155,6 +190,7 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat Amount: totalAmount, Currency: product.Currency, OrderStatus: model.OrderStatusPaid, + OrderSource: orderSource, BuyerReference: in.BuyerReference, RequestFingerprint: fingerprint, RequestData: requestData, @@ -163,6 +199,10 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat return err } idempotencyKey := in.ClientOrderNo + ledgerNote := "开放接口下单扣款(含平台手续费)" + if orderSource == model.OrderSourceManual { + ledgerNote = "商户后台手动下单扣款(含平台手续费)" + } if err := tx.Create(&model.WalletLedgerEntry{ MerchantID: in.MerchantID, WalletAccountID: wallet.ID, @@ -173,7 +213,7 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat ReferenceType: "fulfillment_order", ReferenceNo: order.OrderNo, IdempotencyKey: &idempotencyKey, - Note: "开放接口下单扣款(含平台手续费)", + Note: ledgerNote, }).Error; err != nil { return err } @@ -183,7 +223,11 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat 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 } if s.callbacks != nil { @@ -289,6 +333,7 @@ func (s *FulfillmentService) CreateTestOrder(in CreateTestOrderInput) (*model.Fu Amount: 0, Currency: product.Currency, OrderStatus: in.OrderStatus, + OrderSource: model.OrderSourceTest, BuyerReference: in.BuyerReference, RequestData: string(rawRequestData), } @@ -511,6 +556,7 @@ func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} { "amount": order.Amount, "currency": order.Currency, "order_status": normalizeOrderStatus(order), + "order_source": order.OrderSource, "can_ship": canShip, "cannot_ship_reason": cannotShipReason, "provider_order_no": order.ProviderOrderNo, diff --git a/backend/internal/service/fulfillment_query.go b/backend/internal/service/fulfillment_query.go index 53b196a..74576db 100644 --- a/backend/internal/service/fulfillment_query.go +++ b/backend/internal/service/fulfillment_query.go @@ -23,12 +23,20 @@ func (s *FulfillmentService) GetOrder(merchantID uint, orderNo string) (*model.F 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) tx := s.db.Model(&model.FulfillmentOrder{}).Where("merchant_id = ?", merchantID) if 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 if err := tx.Count(&total).Error; err != nil { return nil, 0, err diff --git a/backend/internal/service/fulfillment_test.go b/backend/internal/service/fulfillment_test.go index 00f441c..c6439d2 100644 --- a/backend/internal/service/fulfillment_test.go +++ b/backend/internal/service/fulfillment_test.go @@ -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) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-idempotency-mismatch", 1000, 5, 200) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index cabc6c5..7ddc0d3 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -7,6 +7,7 @@ import type { ApiCredential, CallbackCredential, CallbackSubscription, + CreateManualOrderResult, CreateTestOrderResult, DeliveryBindResult, DeliveryOrderInfo, @@ -66,6 +67,14 @@ export const merchantApi = { note?: string order_status?: 'paid' | 'ship_failed' | 'cancelled' }) => 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) => request.get(`/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link`).then((r) => r.data.data as DeliveryLinkResult), revokeDeliveryLink: (orderNo: string) => diff --git a/frontend/src/pages/MerchantCenter.tsx b/frontend/src/pages/MerchantCenter.tsx index 4bbcf65..7714a2b 100644 --- a/frontend/src/pages/MerchantCenter.tsx +++ b/frontend/src/pages/MerchantCenter.tsx @@ -2,33 +2,24 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { Button, - Descriptions, Form, Input, InputNumber, Modal, Select, - Space, Tabs, - Tag, Typography, message, } from 'antd' -import { - CopyOutlined, - LinkOutlined, - ReloadOutlined, -} from '@ant-design/icons' +import { ReloadOutlined } from '@ant-design/icons' import { useAuth } from '../store/auth' import { merchantApi } from '../api' -import { formatDateTime } from '../utils/time' import { PageHeader } from '../components/PageHeader' import type { ApiClient, ApiCredential, CallbackCredential, CallbackSubscription, - CreateTestOrderResult, FulfillmentOrder, Merchant, MerchantMember, @@ -48,7 +39,6 @@ import { roleText, scopeOptions, tabFromSearch, - testOrderStatusOptions, } from './merchantCenterUtils' import type { MerchantCenterProps, MerchantCenterTab } from './merchantCenterUtils' import { @@ -71,6 +61,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer const [activeTab, setActiveTab] = useState(routeTab ?? 'products') const [products, setProducts] = useState>({ list: [], total: 0, page: 1, size: 10 }) const [orders, setOrders] = useState>({ list: [], total: 0, page: 1, size: 10 }) + const [orderSource, setOrderSource] = useState<'api' | 'manual'>('api') const [ledger, setLedger] = useState>({ list: [], total: 0, page: 1, size: 10 }) const [wallet, setWallet] = useState(null) const [apiClients, setApiClients] = useState([]) @@ -81,24 +72,24 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer const [apiCredential, setApiCredential] = useState(null) 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 [manualOrderOpen, setManualOrderOpen] = useState(false) + const [manualOrderSubmitting, setManualOrderSubmitting] = useState(false) + const [manualOrderProducts, setManualOrderProducts] = useState([]) + const [manualOrderProductsLoading, setManualOrderProductsLoading] = useState(false) const [walletFilterForm] = Form.useForm() const [apiClientForm] = Form.useForm() const [callbackForm] = Form.useForm() const [memberForm] = Form.useForm() - const [testOrderForm] = Form.useForm() + const [manualOrderForm] = 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) => ({ + const manualOrderProductOptions = useMemo(() => manualOrderProducts.map((item) => ({ value: item.sku, label: productOptionLabel(item), - })), [testOrderProducts]) + })), [manualOrderProducts]) const copyDeliveryLink = useCallback((orderNo: string) => { merchantApi.getDeliveryLink(orderNo) @@ -117,10 +108,6 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer ...prev, 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) => { @@ -157,10 +144,10 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer setProducts(data) }, [products.page, products.size]) - const loadOrders = useCallback(async (page = orders.page, size = orders.size) => { - const data = await merchantApi.orders({ page, size }) + const loadOrders = useCallback(async (page = orders.page, size = orders.size, source = orderSource) => { + const data = await merchantApi.orders({ page, size, order_source: source }) setOrders(data) - }, [orders.page, orders.size]) + }, [orderSource, orders.page, orders.size]) const loadWallet = useCallback(async ( page = ledger.page, @@ -286,21 +273,25 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer }) }, [loadWallet, merchantRole, walletFilterForm]) - const openTestOrderCreate = () => { - testOrderForm.resetFields() - testOrderForm.setFieldsValue({ - buyer_reference: '测试买家', - note: '联调测试订单', - order_status: 'paid', + const newManualClientOrderNo = () => { + const suffix = window.crypto?.randomUUID?.().replaceAll('-', '') || `${Date.now()}${Math.random().toString(36).slice(2)}` + return `manual-${suffix.slice(0, 32)}` + } + + const openManualOrderCreate = () => { + manualOrderForm.resetFields() + manualOrderForm.setFieldsValue({ + client_order_no: newManualClientOrderNo(), + quantity: 1, }) - setTestOrderOpen(true) - setTestOrderProductsLoading(true) + setManualOrderOpen(true) + setManualOrderProductsLoading(true) merchantApi.products({ page: 1, size: 100 }) .then((data) => { const activeProducts = (data.list || []).filter((item) => item.status === 'active') - setTestOrderProducts(activeProducts) + setManualOrderProducts(activeProducts) if (activeProducts.length > 0) { - testOrderForm.setFieldsValue({ sku: activeProducts[0].sku }) + manualOrderForm.setFieldsValue({ sku: activeProducts[0].sku }) } else { message.warning('没有可用商品') } @@ -308,27 +299,37 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer .catch((e) => { message.error(e instanceof Error ? e.message : '商品加载失败') }) - .finally(() => setTestOrderProductsLoading(false)) + .finally(() => setManualOrderProductsLoading(false)) } - const submitTestOrder = async () => { - const values = await testOrderForm.validateFields() + const submitManualOrder = async () => { + const values = await manualOrderForm.validateFields() + setManualOrderSubmitting(true) try { - const result = await merchantApi.createTestOrder({ + const result = await merchantApi.createManualOrder({ + client_order_no: values.client_order_no, sku: values.sku, + quantity: values.quantity, buyer_reference: values.buyer_reference, + game_account: values.game_account, note: values.note, - order_status: values.order_status, }) - setTestOrderOpen(false) - setTestOrderResult(result) - message.success('测试订单已创建') - loadOrders() + setManualOrderOpen(false) + setOrderSource('manual') + message.success(result.idempotent ? '已找到已有手动订单,未重复扣款' : '手动订单已创建,余额与库存已扣减') + await loadOrders(1, orders.size, 'manual') } catch (e) { 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 values = await apiClientForm.validateFields() try { @@ -425,7 +426,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer ) }, { key: 'orders', label: '发货订单', disabled: !hasFeature('orders'), children: ( - + ) }, { key: 'wallet', label: '钱包', disabled: !hasFeature('wallet'), children: ( @@ -483,21 +484,27 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer /> )} - setTestOrderOpen(false)} destroyOnClose width={560}> -
- + setManualOrderOpen(false)} destroyOnClose width={560}> + + + + + + + - - + + + @@ -505,60 +512,6 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer - setTestOrderResult(null)} - footer={( - - - {testOrderResult?.order.delivery_link_revoked_at ? ( - - ) : ( - <> - - - - - )} - - )} - > - {testOrderResult && ( - - - {testOrderResult.order.order_no} - - - {testOrderResult.order.product_sku} - - - - {testOrderResult.can_ship ? 'can_ship=true' : 'can_ship=false'} - - - {!testOrderResult.can_ship && ( - - {testOrderResult.cannot_ship_reason || '-'} - - )} - - {testOrderResult.order.delivery_link_revoked_at ? 已作废 : formatDateTime(testOrderResult.order.delivery_link_expires_at)} - - - 由后端签名生成,可复制、打开或作废。 - - - )} - - setApiClientOpen(false)} destroyOnClose>
+ orderSource: 'api' | 'manual' canManage: boolean - onCreateTestOrder: () => void + onCreateManualOrder: () => void + onOrderSourceChange: (source: 'api' | 'manual') => void onLoadOrders: (page?: number, size?: number) => void onCopyLink: (orderNo: string) => void onOpenLink: (orderNo: string) => void @@ -232,12 +236,17 @@ export function OrdersTab({ return ( - - 发货链接由后端生成并带签名,用户只填写游戏 UID。 + + onOrderSourceChange(key as 'api' | 'manual')} + items={[{ key: 'api', label: 'API 订单' }, { key: 'manual', label: '手动订单' }]} + style={{ minWidth: 220 }} + /> {canManage && ( - )} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4151bdd..28b38c0 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -109,6 +109,7 @@ export interface FulfillmentOrder { amount: number currency: string order_status: string + order_source: 'api' | 'manual' | 'test' buyer_reference: string provider_order_no: string failure_reason: string @@ -125,6 +126,13 @@ export interface CreateTestOrderResult { cannot_ship_reason?: string } +export interface CreateManualOrderResult { + order: FulfillmentOrder + idempotent: boolean + can_ship: boolean + cannot_ship_reason?: string +} + export interface DeliveryProduct { name: string sku: string