Files
junhong_cmp_fiber/internal/exporter/device_scene.go
邹进 62ec94adec
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 38s
refactor: 出库时间更名为首次购买套餐时间,换货新资产不再按换货完成时间记录
- 字段 outbound_at → first_purchase_at,记录表 tb_asset_outbound_record → tb_asset_first_purchase_record,接口 /outbound-records → /first-purchase-records,筛选参数与导出列同步更名
- 换货后的新资产不因换货写入,在当前世代真正支付套餐订单时才记录;记录来源只保留 order / backfill,去掉换货单字段
- 迁移 000266 重命名列、表、索引与约束;同步 Spec、设计、任务与前端接口变更说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:47:02 +09:00

531 lines
17 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"
"strconv"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
const (
deviceExportBaseHeaderCount = 6
deviceExportCardGroupSize = 5
// deviceExportTailHeaderCount 是当前尾部固定列数(含「关联手机号」「设备合计(GB)」列)。
deviceExportTailHeaderCount = 9
// deviceExportPollingTailHeaderCount 是追加「首次购买套餐时间」前(以「是否轮询」结尾)的尾部列数
deviceExportPollingTailHeaderCount = 8
// deviceExportGBTailHeaderCount 是新增「是否轮询」之前、带「设备合计(GB)」的尾部列数,供历史任务表头反解。
deviceExportGBTailHeaderCount = 7
// deviceExportPhoneTailHeaderCount 是新增「设备合计(GB)」之前、带「关联手机号」的尾部列数,供历史任务表头反解。
deviceExportPhoneTailHeaderCount = 6
// deviceExportLegacyTailHeaderCount 是新增「关联手机号」之前的尾部列数,供历史任务表头反解。
deviceExportLegacyTailHeaderCount = 5
deviceExportMinCardGroups = 1
// deviceExportAssociatedPhoneHeader 是「关联手机号」列的表头,固定位于导出尾部。
deviceExportAssociatedPhoneHeader = "关联手机号"
// deviceExportTotalGBHeader 是「设备合计(GB)」列的表头,固定位于导出最后一列。
deviceExportTotalGBHeader = "设备合计(GB)"
// deviceExportPollingHeader 是「是否轮询」列的表头,位于导出最后一列。
deviceExportPollingHeader = "是否轮询"
// deviceExportFirstPurchaseHeader 首次购买套餐时间(当前世代第一笔套餐订单的支付时间)
deviceExportFirstPurchaseHeader = "首次购买套餐时间"
// deviceExportMBPerGB MB 换算 GB 的进制。
deviceExportMBPerGB = 1024
)
// DeviceDataSource 设备导出数据源。
type DeviceDataSource struct {
db *gorm.DB
}
// NewDeviceDataSource 创建设备导出数据源。
func NewDeviceDataSource(db *gorm.DB) *DeviceDataSource {
return &DeviceDataSource{db: db}
}
// Scene 返回导出场景编码。
func (s *DeviceDataSource) Scene() string {
return constants.ExportTaskSceneDevice
}
// Count 统计设备导出行数。
func (s *DeviceDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
var total int64
query := s.applyFilters(s.baseQuery(ctx), params)
if err := query.Count(&total).Error; err != nil {
return 0, err
}
return int(total), nil
}
// Headers 返回设备导出表头。
func (s *DeviceDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
cardGroups, err := s.resolveCardGroupCount(ctx, params)
if err != nil {
return nil, err
}
return buildDeviceExportHeaders(cardGroups), nil
}
// Fetch 按 offset/limit 查询设备导出数据。
func (s *DeviceDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
cardGroups := cardGroupCountFromHeaders(params.ResolvedHeaders)
tailCount := deviceExportTailCount(params.ResolvedHeaders)
if cardGroups <= 0 {
resolved, err := s.resolveCardGroupCount(ctx, params)
if err != nil {
return nil, err
}
cardGroups = resolved
}
devices, err := s.fetchDevices(ctx, params, offset, limit)
if err != nil {
return nil, err
}
if len(devices) == 0 {
return [][]string{}, nil
}
deviceIDs := make([]uint, 0, len(devices))
for _, item := range devices {
deviceIDs = append(deviceIDs, item.ID)
}
cardMap, err := s.fetchBoundCards(ctx, deviceIDs)
if err != nil {
return nil, err
}
packageMap, err := s.fetchActivePackages(ctx, deviceIDs)
if err != nil {
return nil, err
}
associatedPhones, err := loadAssociatedPhonesByAsset(ctx, s.db, constants.AssetTypeDevice, deviceIDs)
if err != nil {
return nil, err
}
rows := make([][]string, 0, len(devices))
for _, item := range devices {
rows = append(rows, buildDeviceExportRow(item, cardMap[item.ID], packageMap[item.ID], associatedPhones[item.ID], cardGroups, tailCount))
}
return rows, nil
}
func (s *DeviceDataSource) baseQuery(ctx context.Context) *gorm.DB {
return s.db.WithContext(ctx).
Table("tb_device AS d").
Where("d.deleted_at IS NULL")
}
// applyFilters 应用与 /api/admin/devices 一致的筛选条件。
// 导出任务运行在 Worker 中,权限只能使用任务创建时保存的店铺范围快照。
func (s *DeviceDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
query = applyExportShopScope(query, params, "d.shop_id")
if start, ok := filterTime(params.Filters, "first_purchase_start"); ok {
query = query.Where("d.first_purchase_at >= ?", start)
}
if end, ok := filterTime(params.Filters, "first_purchase_end"); ok {
query = query.Where("d.first_purchase_at <= ?", end)
}
if virtualNo, ok := filterString(params.Filters, "virtual_no"); ok {
query = query.Where("d.virtual_no LIKE ?", "%"+virtualNo+"%")
}
if imei, ok := filterString(params.Filters, "imei"); ok {
query = query.Where("d.imei LIKE ?", "%"+imei+"%")
}
if keyword, ok := filterString(params.Filters, "keyword"); ok {
like := "%" + keyword + "%"
query = query.Where("d.virtual_no LIKE ? OR d.imei LIKE ?", like, like)
}
if deviceName, ok := filterString(params.Filters, "device_name"); ok {
query = query.Where("d.device_name LIKE ?", "%"+deviceName+"%")
}
if status, ok := filterInt(params.Filters, "status"); ok && status > 0 {
query = query.Where("d.status = ?", status)
}
if activationStatus, ok := filterInt(params.Filters, "activation_status"); ok {
query = s.applyActivationStatusFilter(query, activationStatus)
}
if shopIDs, ok := filterUintSlice(params.Filters, "shop_ids"); ok {
if len(shopIDs) == 0 {
query = query.Where("1 = 0")
} else {
query = query.Where("d.shop_id IN ?", shopIDs)
}
} else if shopID, ok := filterInt(params.Filters, "shop_id"); ok {
if shopID == 0 {
query = query.Where("1 = 0")
} else if shopID > 0 {
query = query.Where("d.shop_id = ?", shopID)
}
}
if batchNo, ok := filterString(params.Filters, "batch_no"); ok {
query = query.Where("d.batch_no = ?", batchNo)
}
if deviceType, ok := filterString(params.Filters, "device_type"); ok {
query = query.Where("d.device_type = ?", deviceType)
}
if manufacturer, ok := filterString(params.Filters, "manufacturer"); ok {
query = query.Where("d.manufacturer LIKE ?", "%"+manufacturer+"%")
}
if start, ok := filterTime(params.Filters, "created_at_start"); ok {
query = query.Where("d.created_at >= ?", start)
}
if end, ok := filterTime(params.Filters, "created_at_end"); ok {
query = query.Where("d.created_at <= ?", end)
}
if seriesID, ok := filterUint(params.Filters, "series_id"); ok {
query = query.Where("d.series_id = ?", seriesID)
}
if hasActivePackage, ok := filterOptionalBool(params.Filters, "has_active_package"); ok {
query = s.applyHasActivePackageFilter(query, hasActivePackage)
}
if enablePolling, ok := filterOptionalBool(params.Filters, "enable_polling"); ok {
query = query.Where("d.enable_polling = ?", enablePolling)
}
return query
}
// applyHasActivePackageFilter 按设备是否有生效中的主套餐过滤。
func (s *DeviceDataSource) applyHasActivePackageFilter(query *gorm.DB, hasActive bool) *gorm.DB {
subQuery := `SELECT 1 FROM tb_package_usage AS pu
WHERE pu.device_id = d.id
AND pu.status = ?
AND pu.master_usage_id IS NULL
AND pu.deleted_at IS NULL`
if hasActive {
return query.Where("EXISTS ("+subQuery+")", constants.PackageUsageStatusActive)
}
return query.Where("NOT EXISTS ("+subQuery+")", constants.PackageUsageStatusActive)
}
func (s *DeviceDataSource) applyActivationStatusFilter(query *gorm.DB, activationStatus int) *gorm.DB {
activeCondition := `EXISTS (
SELECT 1
FROM tb_package_usage AS pu
JOIN tb_device_sim_binding AS b
ON b.device_id = pu.device_id
AND b.bind_status = ?
AND b.deleted_at IS NULL
JOIN tb_iot_card AS c
ON c.id = b.iot_card_id
AND c.real_name_status = ?
AND c.deleted_at IS NULL
WHERE pu.device_id = d.id
AND pu.status = ?
AND pu.master_usage_id IS NULL
AND pu.deleted_at IS NULL
)`
args := []any{
constants.BindStatusBound,
constants.RealNameStatusVerified,
constants.PackageUsageStatusActive,
}
if activationStatus == constants.ActivationStatusActive {
return query.Where(activeCondition, args...)
}
if activationStatus == constants.ActivationStatusInactive {
return query.Where("NOT "+activeCondition, args...)
}
return query
}
func (s *DeviceDataSource) resolveCardGroupCount(ctx context.Context, params ExportParams) (int, error) {
var count int64
query := s.applyFilters(s.baseQuery(ctx), params).
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 = d.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 < deviceExportMinCardGroups {
return deviceExportMinCardGroups, nil
}
return int(count), nil
}
func (s *DeviceDataSource) fetchDevices(ctx context.Context, params ExportParams, offset, limit int) ([]deviceExportRow, error) {
var devices []deviceExportRow
query := s.applyFilters(s.baseQuery(ctx), params).
Joins("LEFT JOIN tb_shop AS sh ON sh.id = d.shop_id").
Joins("LEFT JOIN tb_package_series AS ps ON ps.id = d.series_id AND ps.deleted_at IS NULL").
Joins(`
LEFT JOIN tb_asset_wallet AS aw
ON aw.resource_type = ?
AND aw.resource_id = d.id
AND aw.deleted_at IS NULL
`, constants.AssetWalletResourceTypeDevice).
Select(`
d.id,
d.virtual_no,
d.imei,
d.device_name,
d.device_model,
COALESCE(sh.shop_name, '') AS shop_name,
COALESCE(ps.series_name, '') AS series_name,
COALESCE(aw.balance, 0) AS wallet_balance,
d.enable_polling,
d.first_purchase_at
`).
Order("d.id ASC").
Limit(limit).
Offset(offset)
if err := query.Scan(&devices).Error; err != nil {
return nil, err
}
return devices, nil
}
func (s *DeviceDataSource) fetchBoundCards(ctx context.Context, deviceIDs []uint) (map[uint][]deviceExportCardRow, error) {
result := make(map[uint][]deviceExportCardRow, len(deviceIDs))
if len(deviceIDs) == 0 {
return result, nil
}
var cards []deviceExportCardRow
if err := s.db.WithContext(ctx).
Table("tb_device_sim_binding AS b").
Select(`
b.device_id,
b.slot_position,
c.iccid,
c.msisdn,
c.carrier_name,
c.current_month_usage_mb,
c.network_status
`).
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 *DeviceDataSource) fetchActivePackages(ctx context.Context, deviceIDs []uint) (map[uint]deviceExportPackageRow, error) {
result := make(map[uint]deviceExportPackageRow, len(deviceIDs))
if len(deviceIDs) == 0 {
return result, nil
}
var packages []deviceExportPackageRow
if err := s.db.WithContext(ctx).
Table("tb_package_usage AS pu").
Select(`
pu.device_id,
COALESCE(NULLIF(pu.package_name, ''), p.package_name, '') AS package_name,
pu.activated_at,
pu.expires_at
`).
Joins("LEFT JOIN tb_package AS p ON p.id = pu.package_id AND p.deleted_at IS NULL").
Where("pu.device_id IN ?", deviceIDs).
Where("pu.status = ?", constants.PackageUsageStatusActive).
Where("pu.master_usage_id IS NULL").
Where("pu.deleted_at IS NULL").
Order("pu.device_id ASC, pu.priority ASC, pu.activated_at ASC, pu.id ASC").
Scan(&packages).Error; err != nil {
return nil, err
}
for _, item := range packages {
if _, exists := result[item.DeviceID]; exists {
continue
}
result[item.DeviceID] = item
}
return result, nil
}
type deviceExportRow struct {
ID uint `gorm:"column:id"`
VirtualNo string `gorm:"column:virtual_no"`
IMEI string `gorm:"column:imei"`
DeviceName string `gorm:"column:device_name"`
DeviceModel string `gorm:"column:device_model"`
ShopName string `gorm:"column:shop_name"`
SeriesName string `gorm:"column:series_name"`
WalletBalance int64 `gorm:"column:wallet_balance"`
EnablePolling bool `gorm:"column:enable_polling"`
FirstPurchaseAt *time.Time `gorm:"column:first_purchase_at"`
}
type deviceExportCardRow struct {
DeviceID uint `gorm:"column:device_id"`
SlotPosition int `gorm:"column:slot_position"`
ICCID string `gorm:"column:iccid"`
MSISDN string `gorm:"column:msisdn"`
CarrierName string `gorm:"column:carrier_name"`
CurrentMonthUsageMB float64 `gorm:"column:current_month_usage_mb"`
NetworkStatus int `gorm:"column:network_status"`
}
type deviceExportPackageRow struct {
DeviceID uint `gorm:"column:device_id"`
PackageName string `gorm:"column:package_name"`
ActivatedAt *time.Time `gorm:"column:activated_at"`
ExpiresAt *time.Time `gorm:"column:expires_at"`
}
func buildDeviceExportHeaders(cardGroups int) []string {
if cardGroups < deviceExportMinCardGroups {
cardGroups = deviceExportMinCardGroups
}
headers := []string{"设备号", "IMEI", "设备名称", "店铺名称", "套餐系列", "设备型号"}
for i := 1; i <= cardGroups; i++ {
headers = append(headers,
fmt.Sprintf("ICCID%d", i),
fmt.Sprintf("接入号%d", i),
fmt.Sprintf("ICCID%d对应的运营商", i),
fmt.Sprintf("ICCID%d使用的流量(MB)", i),
fmt.Sprintf("卡状态%d", i),
)
}
headers = append(headers,
"设备合计(MB)",
"套餐的生效时间",
"套餐的到期时间",
"当前套餐",
"钱包余额",
deviceExportAssociatedPhoneHeader,
deviceExportTotalGBHeader,
deviceExportPollingHeader,
deviceExportFirstPurchaseHeader,
)
return headers
}
// buildDeviceExportRow 构造一行设备导出数据;tailCount 为冻结表头的尾部列数,历史表头不输出其后新增的列。
func buildDeviceExportRow(item deviceExportRow, cards []deviceExportCardRow, pkg deviceExportPackageRow, associatedPhone string, cardGroups int, tailCount int) []string {
if cardGroups < deviceExportMinCardGroups {
cardGroups = deviceExportMinCardGroups
}
row := []string{
item.VirtualNo,
item.IMEI,
item.DeviceName,
item.ShopName,
item.SeriesName,
item.DeviceModel,
}
totalUsage := 0.0
for i := 0; i < cardGroups; i++ {
if i >= len(cards) {
row = append(row, "", "", "", "", "")
continue
}
card := cards[i]
totalUsage += card.CurrentMonthUsageMB
row = append(row,
card.ICCID,
card.MSISDN,
card.CarrierName,
formatMB(card.CurrentMonthUsageMB),
constants.GetNetworkStatusName(card.NetworkStatus),
)
}
// 如果表头列数被历史任务固定得比当前绑定卡少,仍保证合计覆盖当前查到的全部卡。
for i := cardGroups; i < len(cards); i++ {
totalUsage += cards[i].CurrentMonthUsageMB
}
row = append(row,
formatMB(totalUsage),
formatOptionalTime(pkg.ActivatedAt),
formatOptionalTime(pkg.ExpiresAt),
pkg.PackageName,
formatMoneyYuan(item.WalletBalance),
)
if tailCount >= deviceExportPhoneTailHeaderCount {
row = append(row, associatedPhone)
}
if tailCount >= deviceExportGBTailHeaderCount {
row = append(row, formatMB(totalUsage/deviceExportMBPerGB))
}
if tailCount >= deviceExportPollingTailHeaderCount {
row = append(row, yesNo(item.EnablePolling))
}
if tailCount >= deviceExportTailHeaderCount {
row = append(row, formatOptionalTime(item.FirstPurchaseAt))
}
return row
}
// cardGroupCountFromHeaders 从已持久化的表头反解卡组列数。
// 先按末列识别新旧表头的尾部列数再反解,否则历史任务凭 ResolvedHeaders 重导出时卡列组数会算错或为 0。
func cardGroupCountFromHeaders(headers []string) int {
return cardGroupCountWithTail(headers, deviceExportTailCount(headers))
}
// deviceExportTailCount 按末列表头识别尾部固定列数:「设备合计(GB)」为当前表头,「关联手机号」为上一版,其余为最早版本。
func deviceExportTailCount(headers []string) int {
if len(headers) == 0 {
return deviceExportTailHeaderCount
}
switch headers[len(headers)-1] {
case deviceExportFirstPurchaseHeader:
return deviceExportTailHeaderCount
case deviceExportPollingHeader:
return deviceExportPollingTailHeaderCount
case deviceExportTotalGBHeader:
return deviceExportGBTailHeaderCount
case deviceExportAssociatedPhoneHeader:
return deviceExportPhoneTailHeaderCount
default:
return deviceExportLegacyTailHeaderCount
}
}
// cardGroupCountWithTail 按指定尾部固定列数反解卡组列数,不整除即无法确定列组。
func cardGroupCountWithTail(headers []string, tailHeaderCount int) int {
if len(headers) < deviceExportBaseHeaderCount+tailHeaderCount {
return 0
}
cardColumnCount := len(headers) - deviceExportBaseHeaderCount - tailHeaderCount
if cardColumnCount <= 0 || cardColumnCount%deviceExportCardGroupSize != 0 {
return 0
}
return cardColumnCount / deviceExportCardGroupSize
}
func formatMB(value float64) string {
return strconv.FormatFloat(value, 'f', 2, 64)
}
func formatMoneyYuan(cents int64) string {
sign := ""
if cents < 0 {
sign = "-"
cents = -cents
}
return fmt.Sprintf("%s%d.%02d", sign, cents/100, cents%100)
}