Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 8m39s
- 迁移 000259~000265:套餐记录沉默期字段与扫描索引、套餐默认沉默期、延期政策/流水/订单/批次/明细 5 张表、默认政策与系统配置(总开关默认关闭)、企微审批白名单、卡与设备出库时间及出库记录 - 沉默期设置(订单发放、自动购包、钱包续费)、后台购买时调整(超默认值按政策免审或企微审批)、统一判定与激活闸门、排队与孤儿扫描跳过过期套餐、过期套餐禁止退款、每 10 分钟扫描 - 总开关专用接口与打开前 4 项检查;延期政策;延期资格与计费;H5 自助与后台二维码在线支付(商户池路由)、支付回调与查单恢复、线下登记、特殊/批量延期、异常重试与原路退款;沉默期审批场景与终态处理 - 换货沉默期流水与出库时间;卡/设备列表、详情、导出与出库记录接口;延期订单与即将过期套餐列表及导出;财务流水纳入延期支付 - 沉默期与出库时间回填工具 cmd/silence-backfill;前端接口变更说明 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
531 lines
17 KiB
Go
531 lines
17 KiB
Go
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 = "是否轮询"
|
||
// deviceExportOutboundHeader 出库时间(当前世代首次购买套餐的支付时间)
|
||
deviceExportOutboundHeader = "出库时间"
|
||
// 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, "outbound_start"); ok {
|
||
query = query.Where("d.outbound_at >= ?", start)
|
||
}
|
||
if end, ok := filterTime(params.Filters, "outbound_end"); ok {
|
||
query = query.Where("d.outbound_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.outbound_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"`
|
||
OutboundAt *time.Time `gorm:"column:outbound_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,
|
||
deviceExportOutboundHeader,
|
||
)
|
||
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.OutboundAt))
|
||
}
|
||
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 deviceExportOutboundHeader:
|
||
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)
|
||
}
|