Files
junhong_cmp_fiber/internal/exporter/order_scene.go
邹进 eb9f12d160
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m34s
feat: 后台订单按平台、代理商、在线支付拆分列表与导出
- 新增 GET /orders/platform、/orders/agent、/orders/online,类别由路径固定,参数、响应、权限同原订单列表
- 新增导出场景 order_platform、order_agent、order_online,筛选与表头同 order
- 类别按钱从哪来划分:客户端下单为在线支付;后台代理钱包扣款(含平台代扣)为代理商;其余后台订单为平台
- 列表与导出共用 internal/query/ordercategory 的类别条件
- 企业账号访问订单列表、详情、购买预检改为无权限,修复其可看到全部订单的问题
- 原订单列表与 order 导出场景保留不变

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:53:24 +09:00

643 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package exporter
import (
"context"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/query/ordercategory"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
const (
orderExportBaseHeaderCount = 11
orderExportCardGroupSize = 2
orderExportMinCardGroups = 1
// orderExportOperatorHeader 操作人列固定追加在最后,历史任务的冻结表头没有该列。
orderExportOperatorHeader = "操作人"
)
// OrderDataSource 订单导出数据源。category 为空时导出全部订单(场景 order),
// 非空时只导出该类别的订单(场景 order_platform / order_agent / order_online),其余逻辑完全相同。
type OrderDataSource struct {
db *gorm.DB
category string
}
// NewOrderDataSource 创建订单导出数据源;category 为空表示不限类别。
func NewOrderDataSource(db *gorm.DB, category string) *OrderDataSource {
return &OrderDataSource{db: db, category: category}
}
// Scene 返回导出场景编码。
func (s *OrderDataSource) Scene() string {
switch s.category {
case constants.OrderCategoryPlatform:
return constants.ExportTaskSceneOrderPlatform
case constants.OrderCategoryAgent:
return constants.ExportTaskSceneOrderAgent
case constants.OrderCategoryOnline:
return constants.ExportTaskSceneOrderOnline
default:
return constants.ExportTaskSceneOrder
}
}
// Count 统计订单导出行数。
func (s *OrderDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
query, err := s.applyFilters(ctx, s.baseQuery(ctx), params)
if err != nil {
return 0, err
}
var total int64
if err := query.Count(&total).Error; err != nil {
return 0, err
}
return int(total), nil
}
// Headers 返回订单导出表头。
func (s *OrderDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
cardGroups, err := s.resolveCardGroupCount(ctx, params)
if err != nil {
return nil, err
}
return buildOrderExportHeaders(cardGroups), nil
}
// Fetch 按 offset/limit 查询订单导出数据。
func (s *OrderDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
cardGroups := orderCardGroupCountFromHeaders(params.ResolvedHeaders)
includeOperator := len(params.ResolvedHeaders) == 0 || orderHeadersIncludeOperator(params.ResolvedHeaders)
if cardGroups <= 0 {
resolved, err := s.resolveCardGroupCount(ctx, params)
if err != nil {
return nil, err
}
cardGroups = resolved
}
orders, err := s.fetchOrders(ctx, params, offset, limit)
if err != nil {
return nil, err
}
if len(orders) == 0 {
return [][]string{}, nil
}
orderIDs := make([]uint, 0, len(orders))
deviceIDs := make([]uint, 0, len(orders))
for _, item := range orders {
orderIDs = append(orderIDs, item.ID)
if item.DeviceID != nil {
deviceIDs = append(deviceIDs, *item.DeviceID)
}
}
itemMap, err := s.fetchOrderItems(ctx, orderIDs)
if err != nil {
return nil, err
}
cardMap, err := s.fetchBoundCards(ctx, deviceIDs)
if err != nil {
return nil, err
}
paymentMap, err := s.fetchPreferredPayments(ctx, orderIDs)
if err != nil {
return nil, err
}
rows := make([][]string, 0, len(orders))
for _, item := range orders {
var cards []orderExportCardRow
if item.DeviceID != nil {
cards = cardMap[*item.DeviceID]
}
row := buildOrderExportRow(item, itemMap[item.ID], paymentMap[item.ID], cards, cardGroups)
if includeOperator {
row = append(row, item.OperatorAccountName)
}
rows = append(rows, row)
}
return rows, nil
}
func (s *OrderDataSource) baseQuery(ctx context.Context) *gorm.DB {
return s.db.WithContext(ctx).
Table("tb_order AS o").
Where("o.deleted_at IS NULL")
}
// applyFilters 应用与 /api/admin/orders 当前实际行为一致的筛选条件。
// is_expired 在接口 Service 中未进入 Store 筛选,这里按当前接口行为忽略。
func (s *OrderDataSource) applyFilters(ctx context.Context, query *gorm.DB, params ExportParams) (*gorm.DB, error) {
query = s.applyOrderScope(query, params)
// 订单类别与列表共用同一份条件,导出条数与对应列表一致。
if s.category != "" {
condition, args, ok := ordercategory.Condition("o", s.category)
if !ok {
return nil, fmt.Errorf("不支持的订单类别: %s", s.category)
}
query = query.Where(condition, args...)
}
if params.UserType == constants.UserTypeAgent {
if params.CreatorShopID != nil {
query = query.Where("o.buyer_type = ? AND o.buyer_id = ?", model.BuyerTypeAgent, *params.CreatorShopID)
} else {
query = query.Where("o.buyer_type = ?", model.BuyerTypeAgent)
}
}
if paymentStatus, ok := filterInt(params.Filters, "payment_status"); ok {
query = query.Where("o.payment_status = ?", paymentStatus)
}
if paymentMethod, ok := filterString(params.Filters, "payment_method"); ok {
query = query.Where("o.payment_method = ?", paymentMethod)
}
if orderType, ok := filterString(params.Filters, "order_type"); ok {
query = query.Where("o.order_type = ?", orderType)
}
if orderNo, ok := filterString(params.Filters, "order_no"); ok {
query = query.Where("o.order_no = ?", orderNo)
}
if purchaseRole, ok := filterString(params.Filters, "purchase_role"); ok {
query = query.Where("o.purchase_role = ?", purchaseRole)
}
if sellerShopID, ok := filterUint(params.Filters, "seller_shop_id"); ok {
query = query.Where("o.seller_shop_id = ?", sellerShopID)
}
start, end, err := strictTimeRange(params.Filters)
if err != nil {
return nil, err
}
if start != nil {
query = query.Where("o.created_at >= ?", *start)
}
if end != nil {
query = query.Where("o.created_at <= ?", *end)
}
if buyerPhone, ok := filterString(params.Filters, "buyer_phone"); ok {
query = query.Where("o.buyer_phone = ?", buyerPhone)
}
if identifier, ok := filterString(params.Filters, "identifier"); ok {
iotCardID, deviceID, err := s.resolveOrderListAssetIdentifier(ctx, params, identifier)
if err != nil {
return nil, err
}
assetQuery := s.db.Where("o.asset_identifier = ?", identifier)
if iotCardID != nil {
assetQuery = assetQuery.Or("o.iot_card_id = ?", *iotCardID)
}
if deviceID != nil {
assetQuery = assetQuery.Or("o.device_id = ?", *deviceID)
}
query = query.Where(assetQuery)
}
return query, nil
}
func (s *OrderDataSource) applyOrderScope(query *gorm.DB, params ExportParams) *gorm.DB {
switch params.UserType {
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
return query
case constants.UserTypeAgent:
if len(params.ScopeShopIDs) == 0 {
return query.Where("1 = 0")
}
return query.Where(
s.db.Where("o.buyer_type = ? AND o.buyer_id IN ?", model.BuyerTypeAgent, params.ScopeShopIDs).
Or("o.seller_shop_id IN ?", params.ScopeShopIDs).
Or(s.db.Where("o.seller_shop_id IS NULL AND o.operator_type = ? AND o.operator_id IN ?", model.OperatorAccountTypeAgent, params.ScopeShopIDs)),
)
default:
return query.Where("1 = 0")
}
}
func (s *OrderDataSource) resolveCardGroupCount(ctx context.Context, params ExportParams) (int, error) {
filtered, err := s.applyFilters(ctx, s.baseQuery(ctx), params)
if err != nil {
return 0, err
}
var count int64
query := filtered.
Select("COALESCE(MAX(bound_cards.card_count), 0)").
Joins(`
LEFT JOIN LATERAL (
SELECT COUNT(*) AS card_count
FROM tb_device_sim_binding AS b
WHERE b.device_id = o.device_id
AND b.bind_status = ?
AND b.deleted_at IS NULL
) AS bound_cards ON TRUE
`, constants.BindStatusBound)
if err := query.Scan(&count).Error; err != nil {
return 0, err
}
if count < orderExportMinCardGroups {
return orderExportMinCardGroups, nil
}
return int(count), nil
}
func (s *OrderDataSource) fetchOrders(ctx context.Context, params ExportParams, offset, limit int) ([]orderExportRow, error) {
var orders []orderExportRow
filtered, err := s.applyFilters(ctx, s.baseQuery(ctx), params)
if err != nil {
return nil, err
}
query := filtered.
Joins("LEFT JOIN tb_shop AS sh ON sh.id = o.seller_shop_id").
Select(`
o.id,
o.order_no,
o.asset_identifier,
o.seller_cost_price,
o.total_amount,
o.actual_paid_amount,
o.payment_status,
o.payment_method,
o.created_at,
o.device_id,
o.operator_account_name,
COALESCE(sh.shop_name, '') AS shop_name
`).
Order("o.id DESC").
Limit(limit).
Offset(offset)
if err := query.Scan(&orders).Error; err != nil {
return nil, err
}
return orders, nil
}
func (s *OrderDataSource) fetchOrderItems(ctx context.Context, orderIDs []uint) (map[uint][]orderExportItemRow, error) {
result := make(map[uint][]orderExportItemRow, len(orderIDs))
if len(orderIDs) == 0 {
return result, nil
}
var items []orderExportItemRow
if err := s.db.WithContext(ctx).
Table("tb_order_item").
Select("order_id, package_name, quantity").
Where("order_id IN ?", orderIDs).
Where("deleted_at IS NULL").
Order("order_id ASC, id ASC").
Scan(&items).Error; err != nil {
return nil, err
}
for _, item := range items {
result[item.OrderID] = append(result[item.OrderID], item)
}
return result, nil
}
func (s *OrderDataSource) fetchBoundCards(ctx context.Context, deviceIDs []uint) (map[uint][]orderExportCardRow, error) {
result := make(map[uint][]orderExportCardRow, len(deviceIDs))
deviceIDs = normalizeUintSlice(deviceIDs)
if len(deviceIDs) == 0 {
return result, nil
}
var cards []orderExportCardRow
if err := s.db.WithContext(ctx).
Table("tb_device_sim_binding AS b").
Select(`
b.device_id,
b.slot_position,
c.msisdn,
c.iccid
`).
Joins("JOIN tb_iot_card AS c ON c.id = b.iot_card_id AND c.deleted_at IS NULL").
Where("b.device_id IN ?", deviceIDs).
Where("b.bind_status = ?", constants.BindStatusBound).
Where("b.deleted_at IS NULL").
Order("b.device_id ASC, b.slot_position ASC, b.id ASC").
Scan(&cards).Error; err != nil {
return nil, err
}
for _, card := range cards {
result[card.DeviceID] = append(result[card.DeviceID], card)
}
return result, nil
}
func (s *OrderDataSource) fetchPreferredPayments(ctx context.Context, orderIDs []uint) (map[uint]orderExportPaymentRow, error) {
result := make(map[uint]orderExportPaymentRow, len(orderIDs))
if len(orderIDs) == 0 {
return result, nil
}
var payments []orderExportPaymentRow
if err := s.db.WithContext(ctx).
Table("tb_payment").
Select("order_id, third_party_trade_no, status").
Where("order_id IN ?", orderIDs).
Where("order_type = ?", model.PaymentOrderTypePackage).
Where("deleted_at IS NULL").
Order("order_id ASC, CASE WHEN status = 1 THEN 0 ELSE 1 END ASC, created_at DESC, id DESC").
Scan(&payments).Error; err != nil {
return nil, err
}
for _, payment := range payments {
if _, exists := result[payment.OrderID]; exists {
continue
}
result[payment.OrderID] = payment
}
return result, nil
}
func (s *OrderDataSource) resolveOrderListAssetIdentifier(ctx context.Context, params ExportParams, identifier string) (*uint, *uint, error) {
if identifier == "" {
return nil, nil, nil
}
var registry orderExportAssetIdentifierRow
registryErr := s.db.WithContext(ctx).
Table("tb_asset_identifier").
Select("asset_type, asset_id").
Where("identifier = ?", identifier).
First(&registry).Error
if registryErr != nil && registryErr != gorm.ErrRecordNotFound {
return nil, nil, registryErr
}
if registryErr == nil {
switch registry.AssetType {
case model.AssetTypeIotCard:
if s.assetVisible(ctx, params, "tb_iot_card", registry.AssetID) {
cardID := registry.AssetID
return &cardID, nil, nil
}
case model.AssetTypeDevice:
if s.assetVisible(ctx, params, "tb_device", registry.AssetID) {
deviceID := registry.AssetID
return nil, &deviceID, nil
}
}
}
if deviceID, err := s.resolveDeviceIDByIdentifier(ctx, params, identifier); err != nil {
return nil, nil, err
} else if deviceID != nil {
return nil, deviceID, nil
}
cardID, err := s.resolveCardIDByIdentifier(ctx, params, identifier)
if err != nil {
return nil, nil, err
}
return cardID, nil, nil
}
func (s *OrderDataSource) assetVisible(ctx context.Context, params ExportParams, table string, id uint) bool {
var count int64
query := s.db.WithContext(ctx).Table(table).Where("id = ? AND deleted_at IS NULL", id)
if params.UserType == constants.UserTypeAgent {
if len(params.ScopeShopIDs) == 0 {
return false
}
query = query.Where("shop_id IN ?", params.ScopeShopIDs)
}
return query.Count(&count).Error == nil && count > 0
}
func (s *OrderDataSource) resolveDeviceIDByIdentifier(ctx context.Context, params ExportParams, identifier string) (*uint, error) {
var row struct {
ID uint `gorm:"column:id"`
}
query := s.db.WithContext(ctx).
Table("tb_device").
Select("id").
Where("deleted_at IS NULL").
Where("virtual_no = ? OR imei = ? OR sn = ?", identifier, identifier, identifier)
if params.UserType == constants.UserTypeAgent {
if len(params.ScopeShopIDs) == 0 {
return nil, nil
}
query = query.Where("shop_id IN ?", params.ScopeShopIDs)
}
err := query.First(&row).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &row.ID, nil
}
func (s *OrderDataSource) resolveCardIDByIdentifier(ctx context.Context, params ExportParams, identifier string) (*uint, error) {
conditions := []string{"virtual_no = ?", "msisdn = ?", "iccid = ?"}
args := []any{identifier, identifier, identifier}
if len(identifier) == 19 {
conditions = append(conditions, "iccid_19 = ?")
args = append(args, identifier)
} else if len(identifier) == 20 {
conditions = append(conditions, "iccid_20 = ?")
args = append(args, identifier)
}
var row struct {
ID uint `gorm:"column:id"`
}
query := s.db.WithContext(ctx).
Table("tb_iot_card").
Select("id").
Where("deleted_at IS NULL").
Where(strings.Join(conditions, " OR "), args...)
if params.UserType == constants.UserTypeAgent {
if len(params.ScopeShopIDs) == 0 {
return nil, nil
}
query = query.Where("shop_id IN ?", params.ScopeShopIDs)
}
err := query.First(&row).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &row.ID, nil
}
type orderExportRow struct {
ID uint `gorm:"column:id"`
OrderNo string `gorm:"column:order_no"`
ShopName string `gorm:"column:shop_name"`
AssetIdentifier string `gorm:"column:asset_identifier"`
SellerCostPrice int64 `gorm:"column:seller_cost_price"`
TotalAmount int64 `gorm:"column:total_amount"`
ActualPaidAmount *int64 `gorm:"column:actual_paid_amount"`
PaymentStatus int `gorm:"column:payment_status"`
PaymentMethod string `gorm:"column:payment_method"`
CreatedAt time.Time `gorm:"column:created_at"`
DeviceID *uint `gorm:"column:device_id"`
// OperatorAccountName 订单创建时冻结的操作者名称,缺失时导出为空,不回查当前账号。
OperatorAccountName string `gorm:"column:operator_account_name"`
}
type orderExportItemRow struct {
OrderID uint `gorm:"column:order_id"`
PackageName string `gorm:"column:package_name"`
Quantity int `gorm:"column:quantity"`
}
type orderExportPaymentRow struct {
OrderID uint `gorm:"column:order_id"`
ThirdPartyTradeNo string `gorm:"column:third_party_trade_no"`
Status int `gorm:"column:status"`
}
type orderExportCardRow struct {
DeviceID uint `gorm:"column:device_id"`
SlotPosition int `gorm:"column:slot_position"`
MSISDN string `gorm:"column:msisdn"`
ICCID string `gorm:"column:iccid"`
}
type orderExportAssetIdentifierRow struct {
AssetType string `gorm:"column:asset_type"`
AssetID uint `gorm:"column:asset_id"`
}
func buildOrderExportHeaders(cardGroups int) []string {
if cardGroups < orderExportMinCardGroups {
cardGroups = orderExportMinCardGroups
}
headers := []string{
"订单号",
"店铺",
"虚拟号",
"套餐名称",
"成本价",
"下单金额",
"支付状态",
"支付方式",
"数量",
"下单时间",
"第三方单号",
}
for i := 1; i <= cardGroups; i++ {
headers = append(headers,
fmt.Sprintf("接入号%d", i),
fmt.Sprintf("ICCID%d", i),
)
}
return append(headers, orderExportOperatorHeader)
}
func buildOrderExportRow(item orderExportRow, items []orderExportItemRow, payment orderExportPaymentRow, cards []orderExportCardRow, cardGroups int) []string {
if cardGroups < orderExportMinCardGroups {
cardGroups = orderExportMinCardGroups
}
row := []string{
item.OrderNo,
item.ShopName,
item.AssetIdentifier,
joinOrderPackageNames(items),
formatMoneyYuan(item.SellerCostPrice),
formatMoneyYuan(orderActualPaidAmount(item)),
constants.GetOrderPaymentStatusName(item.PaymentStatus),
formatOrderPaymentMethod(item.PaymentMethod),
formatOrderQuantity(items),
item.CreatedAt.Format(exportTimeLayout),
payment.ThirdPartyTradeNo,
}
for i := 0; i < cardGroups; i++ {
if i >= len(cards) {
row = append(row, "", "")
continue
}
row = append(row, cards[i].MSISDN, cards[i].ICCID)
}
return row
}
func orderCardGroupCountFromHeaders(headers []string) int {
fixedCount := orderExportBaseHeaderCount
if orderHeadersIncludeOperator(headers) {
fixedCount++
}
if len(headers) < fixedCount {
return 0
}
cardColumnCount := len(headers) - fixedCount
if cardColumnCount <= 0 || cardColumnCount%orderExportCardGroupSize != 0 {
return 0
}
return cardColumnCount / orderExportCardGroupSize
}
// orderHeadersIncludeOperator 判断冻结表头是否为带“操作人”末列的新表头。
func orderHeadersIncludeOperator(headers []string) bool {
return len(headers) > 0 && headers[len(headers)-1] == orderExportOperatorHeader
}
func joinOrderPackageNames(items []orderExportItemRow) string {
if len(items) == 0 {
return ""
}
names := make([]string, 0, len(items))
for _, item := range items {
if item.PackageName != "" {
names = append(names, item.PackageName)
}
}
return strings.Join(names, ",")
}
func formatOrderQuantity(items []orderExportItemRow) string {
if len(items) == 0 {
return "0"
}
total := 0
for _, item := range items {
total += item.Quantity
}
return fmt.Sprintf("%d", total)
}
func orderActualPaidAmount(item orderExportRow) int64 {
if item.ActualPaidAmount != nil {
return *item.ActualPaidAmount
}
if item.PaymentStatus == model.PaymentStatusPaid {
return item.TotalAmount
}
return 0
}
func formatOrderPaymentMethod(method string) string {
switch method {
case model.PaymentMethodWallet:
return "钱包支付"
case model.PaymentMethodWechat:
return "微信支付"
case model.PaymentMethodAlipay:
return "支付宝支付"
case model.PaymentMethodOffline:
return "线下支付"
default:
return method
}
}