574 lines
18 KiB
Go
574 lines
18 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
|
|
"affiliate_dash/internal/model"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
type FulfillmentService struct {
|
|
db *gorm.DB
|
|
callbacks *CallbackService
|
|
}
|
|
|
|
func NewFulfillmentService(db *gorm.DB, callbacks *CallbackService) *FulfillmentService {
|
|
return &FulfillmentService{db: db, callbacks: callbacks}
|
|
}
|
|
|
|
type CreateFulfillmentOrderInput struct {
|
|
MerchantID uint
|
|
APIClientID uint
|
|
ActorUserID uint
|
|
ClientOrderNo string
|
|
SKU string
|
|
Quantity int64
|
|
BuyerReference string
|
|
RequestData interface{}
|
|
OrderSource string
|
|
}
|
|
|
|
type CreateFulfillmentOrderResult struct {
|
|
Order *model.FulfillmentOrder `json:"order"`
|
|
Idempotent bool `json:"idempotent"`
|
|
}
|
|
|
|
type CreateTestOrderInput struct {
|
|
MerchantID uint
|
|
ActorUserID uint
|
|
SKU string
|
|
BuyerReference string
|
|
Note 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) {
|
|
in.ClientOrderNo = strings.TrimSpace(in.ClientOrderNo)
|
|
in.SKU = strings.TrimSpace(in.SKU)
|
|
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 位")
|
|
}
|
|
if in.SKU == "" {
|
|
return nil, errors.New("sku 不能为空")
|
|
}
|
|
if in.Quantity == 0 {
|
|
in.Quantity = 1
|
|
}
|
|
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)
|
|
if err != nil {
|
|
return nil, errors.New("订单请求数据无法序列化")
|
|
}
|
|
requestData = string(raw)
|
|
}
|
|
fingerprint := orderRequestFingerprint(in, requestData)
|
|
|
|
result := &CreateFulfillmentOrderResult{}
|
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
|
var existing model.FulfillmentOrder
|
|
err := tx.Where("merchant_id = ? AND client_order_no = ?", in.MerchantID, in.ClientOrderNo).First(&existing).Error
|
|
if err == nil {
|
|
if err := ensureSameIdempotentOrder(&existing, fingerprint); err != nil {
|
|
return err
|
|
}
|
|
result.Order = &existing
|
|
result.Idempotent = true
|
|
return nil
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
|
|
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").Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
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("商品目录已下架")
|
|
}
|
|
if product.Stock >= 0 && product.Stock < in.Quantity {
|
|
return errors.New("商品库存不足")
|
|
}
|
|
if product.PriceAmount > 0 && in.Quantity > math.MaxInt64/product.PriceAmount {
|
|
return errors.New("订单金额超出范围")
|
|
}
|
|
baseAmount := product.PriceAmount * in.Quantity
|
|
serviceFee, err := calculateServiceFee(baseAmount, merchant.FeeType, merchant.FeeRateBP, merchant.FeeFixedAmount)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if serviceFee > math.MaxInt64-baseAmount {
|
|
return errors.New("订单金额超出范围")
|
|
}
|
|
totalAmount := baseAmount + serviceFee
|
|
|
|
var wallet model.WalletAccount
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where("merchant_id = ?", in.MerchantID).First(&wallet).Error; err != nil {
|
|
return err
|
|
}
|
|
if wallet.AvailableBalance < totalAmount {
|
|
return errors.New("商户钱包余额不足")
|
|
}
|
|
newBalance := wallet.AvailableBalance - totalAmount
|
|
if err := tx.Model(&wallet).Update("available_balance", newBalance).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
order := &model.FulfillmentOrder{
|
|
MerchantID: in.MerchantID,
|
|
OrderNo: newFulfillmentOrderNo(),
|
|
ClientOrderNo: in.ClientOrderNo,
|
|
MerchantProductID: product.ID,
|
|
ProductSKU: product.SKU,
|
|
ProductName: fallbackName(product.DisplayName, product.Product.Name),
|
|
Quantity: in.Quantity,
|
|
BaseAmount: baseAmount,
|
|
FeeType: merchant.FeeType,
|
|
FeeRateBP: merchant.FeeRateBP,
|
|
FeeFixedAmount: merchant.FeeFixedAmount,
|
|
ServiceFeeAmount: serviceFee,
|
|
Amount: totalAmount,
|
|
Currency: product.Currency,
|
|
OrderStatus: model.OrderStatusPaid,
|
|
OrderSource: orderSource,
|
|
BuyerReference: in.BuyerReference,
|
|
RequestFingerprint: fingerprint,
|
|
RequestData: requestData,
|
|
}
|
|
if err := tx.Create(order).Error; err != nil {
|
|
return err
|
|
}
|
|
idempotencyKey := in.ClientOrderNo
|
|
ledgerNote := "开放接口下单扣款(含平台手续费)"
|
|
if orderSource == model.OrderSourceManual {
|
|
ledgerNote = "商户后台手动下单扣款(含平台手续费)"
|
|
}
|
|
if err := tx.Create(&model.WalletLedgerEntry{
|
|
MerchantID: in.MerchantID,
|
|
WalletAccountID: wallet.ID,
|
|
EntryNo: "WL" + uuid.NewString(),
|
|
Type: model.WalletLedgerDebit,
|
|
Amount: -totalAmount,
|
|
BalanceAfter: newBalance,
|
|
ReferenceType: "fulfillment_order",
|
|
ReferenceNo: order.OrderNo,
|
|
IdempotencyKey: &idempotencyKey,
|
|
Note: ledgerNote,
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
if product.Stock >= 0 {
|
|
if err := tx.Model(&model.MerchantProduct{}).Where("id = ? AND stock >= ?", product.ID, in.Quantity).
|
|
Update("stock", gorm.Expr("stock - ?", in.Quantity)).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
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 {
|
|
if err := s.callbacks.Enqueue(tx, in.MerchantID, "order.created", orderCallbackData(order)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
result.Order = order
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
// 并发请求恰好同时通过首次查询时,唯一约束冲突后返回既有订单。
|
|
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "UNIQUE") {
|
|
var existing model.FulfillmentOrder
|
|
if queryErr := s.db.Where("merchant_id = ? AND client_order_no = ?", in.MerchantID, in.ClientOrderNo).First(&existing).Error; queryErr == nil {
|
|
if sameErr := ensureSameIdempotentOrder(&existing, fingerprint); sameErr != nil {
|
|
return nil, sameErr
|
|
}
|
|
return &CreateFulfillmentOrderResult{Order: &existing, Idempotent: true}, nil
|
|
}
|
|
}
|
|
return nil, err
|
|
}
|
|
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.OrderStatus == "" {
|
|
in.OrderStatus = model.OrderStatusPaid
|
|
}
|
|
switch in.OrderStatus {
|
|
case model.OrderStatusPaid, model.OrderStatusShipFailed, model.OrderStatusCancelled:
|
|
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,
|
|
OrderStatus: in.OrderStatus,
|
|
OrderSource: model.OrderSourceTest,
|
|
BuyerReference: in.BuyerReference,
|
|
RequestData: string(rawRequestData),
|
|
}
|
|
if in.OrderStatus == model.OrderStatusShipFailed {
|
|
order.FailureReason = fallbackName(in.Note, "联调测试订单初始化为发货失败,可重新发货")
|
|
}
|
|
if in.OrderStatus == model.OrderStatusCancelled {
|
|
now := time.Now()
|
|
order.CancelledAt = &now
|
|
}
|
|
if err := tx.Create(order).Error; err != nil {
|
|
return err
|
|
}
|
|
canShip, _ := CanFulfill(order)
|
|
if err := writeAudit(tx, &in.MerchantID, &in.ActorUserID, nil, "merchant_test_order.create", "fulfillment_order", order.OrderNo, map[string]interface{}{
|
|
"sku": in.SKU,
|
|
"order_status": in.OrderStatus,
|
|
"can_ship": canShip,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
out = *order
|
|
out.MerchantProduct = &product
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
type FulfillmentUpdateInput struct {
|
|
MerchantID uint
|
|
APIClientID uint
|
|
OrderNo string
|
|
Status string
|
|
ProviderOrderNo string
|
|
FailureReason string
|
|
ResultData interface{}
|
|
}
|
|
|
|
func (s *FulfillmentService) UpdateFulfillment(in FulfillmentUpdateInput) (*model.FulfillmentOrder, error) {
|
|
switch in.Status {
|
|
case model.OrderStatusDelivering, model.OrderStatusDelivered, model.OrderStatusShipFailed:
|
|
default:
|
|
return nil, errors.New("无效的订单状态")
|
|
}
|
|
nextOrderStatus := in.Status
|
|
var resultData string
|
|
hasResultData := in.ResultData != nil
|
|
if in.ResultData != nil {
|
|
raw, err := json.Marshal(in.ResultData)
|
|
if err != nil {
|
|
return nil, errors.New("发货结果无法序列化")
|
|
}
|
|
resultData = string(raw)
|
|
}
|
|
var out model.FulfillmentOrder
|
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
|
var order model.FulfillmentOrder
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where("merchant_id = ? AND order_no = ?", in.MerchantID, in.OrderNo).
|
|
First(&order).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return errors.New("订单不存在")
|
|
}
|
|
return err
|
|
}
|
|
if normalizeOrderStatus(&order) == model.OrderStatusDelivered && nextOrderStatus == model.OrderStatusDelivered {
|
|
out = order
|
|
return nil
|
|
}
|
|
if err := validateOrderStatusTransition(&order, nextOrderStatus, fulfillmentTransitionUpdate); err != nil {
|
|
return err
|
|
}
|
|
now := time.Now()
|
|
updates := map[string]interface{}{"order_status": nextOrderStatus}
|
|
if hasResultData {
|
|
updates["result_data"] = resultData
|
|
}
|
|
if in.ProviderOrderNo != "" {
|
|
updates["provider_order_no"] = in.ProviderOrderNo
|
|
}
|
|
switch nextOrderStatus {
|
|
case model.OrderStatusDelivered:
|
|
updates["delivered_at"] = now
|
|
updates["failure_reason"] = ""
|
|
case model.OrderStatusShipFailed:
|
|
updates["failure_reason"] = in.FailureReason
|
|
}
|
|
if err := tx.Model(&order).Updates(updates).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.First(&out, order.ID).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := writeAudit(tx, &in.MerchantID, nil, &in.APIClientID, "fulfillment.update", "fulfillment_order", order.OrderNo, map[string]string{"status": nextOrderStatus}); err != nil {
|
|
return err
|
|
}
|
|
if s.callbacks != nil {
|
|
if err := s.callbacks.Enqueue(tx, in.MerchantID, "order.shipping.updated", orderCallbackData(&out)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo, reason string) (*model.FulfillmentOrder, error) {
|
|
var out model.FulfillmentOrder
|
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
|
var order model.FulfillmentOrder
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where("merchant_id = ? AND order_no = ?", merchantID, orderNo).
|
|
First(&order).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return errors.New("订单不存在")
|
|
}
|
|
return err
|
|
}
|
|
if normalizeOrderStatus(&order) == model.OrderStatusCancelled {
|
|
out = order
|
|
return nil
|
|
}
|
|
if err := canCancelOrder(&order); err != nil {
|
|
return err
|
|
}
|
|
now := time.Now()
|
|
updates := map[string]interface{}{"order_status": model.OrderStatusCancelled}
|
|
updates["failure_reason"] = reason
|
|
updates["cancelled_at"] = now
|
|
if err := tx.Model(&order).Updates(updates).Error; err != nil {
|
|
return err
|
|
}
|
|
var wallet model.WalletAccount
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil {
|
|
return err
|
|
}
|
|
newBalance := wallet.AvailableBalance + order.Amount
|
|
if err := tx.Model(&wallet).Update("available_balance", newBalance).Error; err != nil {
|
|
return err
|
|
}
|
|
idempotencyKey := "cancel:" + order.OrderNo
|
|
if err := tx.Create(&model.WalletLedgerEntry{
|
|
MerchantID: merchantID,
|
|
WalletAccountID: wallet.ID,
|
|
EntryNo: "WL" + uuid.NewString(),
|
|
Type: model.WalletLedgerRefund,
|
|
Amount: order.Amount,
|
|
BalanceAfter: newBalance,
|
|
ReferenceType: "fulfillment_order",
|
|
ReferenceNo: order.OrderNo,
|
|
IdempotencyKey: &idempotencyKey,
|
|
Note: "订单取消退款",
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
var product model.MerchantProduct
|
|
if err := tx.Where("id = ?", order.MerchantProductID).First(&product).Error; err != nil {
|
|
return err
|
|
}
|
|
if product.Stock >= 0 {
|
|
if err := tx.Model(&product).Update("stock", gorm.Expr("stock + ?", order.Quantity)).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := tx.First(&out, order.ID).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := writeAudit(tx, &merchantID, nil, &apiClientID, "open_order.cancel", "fulfillment_order", order.OrderNo, nil); err != nil {
|
|
return err
|
|
}
|
|
if s.callbacks != nil {
|
|
if err := s.callbacks.Enqueue(tx, merchantID, "order.cancelled", orderCallbackData(&out)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func CanFulfill(order *model.FulfillmentOrder) (bool, string) {
|
|
switch normalizeOrderStatus(order) {
|
|
case model.OrderStatusPaid:
|
|
return true, ""
|
|
case model.OrderStatusShipFailed:
|
|
if deliverySubmittedUpstream(order) {
|
|
return false, "订单已提交上游,为避免重复发货请先确认上游状态"
|
|
}
|
|
return true, ""
|
|
case model.OrderStatusDelivering:
|
|
return false, "订单发货中"
|
|
case model.OrderStatusDelivered:
|
|
return false, "订单已交付"
|
|
case model.OrderStatusCancelled:
|
|
return false, "订单已取消"
|
|
default:
|
|
return false, "订单状态不可发货"
|
|
}
|
|
}
|
|
|
|
func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} {
|
|
canShip, cannotShipReason := CanFulfill(order)
|
|
data := map[string]interface{}{
|
|
"order_no": order.OrderNo,
|
|
"client_order_no": order.ClientOrderNo,
|
|
"product_sku": order.ProductSKU,
|
|
"quantity": order.Quantity,
|
|
"base_amount": order.BaseAmount,
|
|
"fee_type": order.FeeType,
|
|
"service_fee_amount": order.ServiceFeeAmount,
|
|
"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,
|
|
"failure_reason": order.FailureReason,
|
|
}
|
|
if requestData := requestDataMap(order.RequestData); len(requestData) > 0 {
|
|
data["data"] = requestData
|
|
}
|
|
return data
|
|
}
|
|
|
|
// ----- 仪表盘统计 -----
|
|
|
|
// DashboardStats 仪表盘聚合指标。
|