七月迭代短暂完结,还有很多后端的关键东西没有弄,这是一版赶时间做的东西
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
@@ -57,10 +58,26 @@ func (q *ListQuery) List(ctx context.Context, req *dto.ExchangeListRequest) (*dt
|
||||
if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&orders).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单列表失败")
|
||||
}
|
||||
submitterIDs := make([]uint, 0, len(orders))
|
||||
for _, order := range orders {
|
||||
if order.Creator > 0 {
|
||||
submitterIDs = append(submitterIDs, order.Creator)
|
||||
}
|
||||
}
|
||||
accounts, err := postgres.NewAccountStore(q.db, nil).GetDisplayAccountsByIDs(ctx, submitterIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货提交人失败")
|
||||
}
|
||||
submitterNames := make(map[uint]string, len(accounts))
|
||||
for _, account := range accounts {
|
||||
submitterNames[account.ID] = account.Username
|
||||
}
|
||||
|
||||
items := make([]*dto.ExchangeOrderResponse, 0, len(orders))
|
||||
for _, order := range orders {
|
||||
items = append(items, projectExchangeOrder(order))
|
||||
item := projectExchangeOrder(order)
|
||||
item.SubmitterName = submitterNames[order.Creator]
|
||||
items = append(items, item)
|
||||
}
|
||||
return &dto.ExchangeListResponse{List: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
@@ -121,7 +138,7 @@ func projectExchangeOrder(order *model.ExchangeOrder) *dto.ExchangeOrderResponse
|
||||
ExchangeReason: order.ExchangeReason, Remark: order.Remark,
|
||||
Status: order.Status, StatusName: constants.GetExchangeStatusName(order.Status), StatusText: constants.GetExchangeStatusName(order.Status),
|
||||
ShopID: order.ShopID, CreatedAt: order.CreatedAt, UpdatedAt: order.UpdatedAt, DeletedAt: deletedAt,
|
||||
Creator: order.Creator, Updater: order.Updater,
|
||||
SubmitterID: order.Creator, Creator: order.Creator, Updater: order.Updater,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,9 @@ func (q *Query) PersonalList(ctx context.Context, customerID uint, request dto.P
|
||||
return nil, err
|
||||
}
|
||||
base := personalNotificationScope(q.db.WithContext(ctx).Model(&model.Notification{}), customerID, q.now().UTC())
|
||||
if request.IsRead != nil {
|
||||
base = base.Where("is_read = ?", *request.IsRead)
|
||||
}
|
||||
var total int64
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询个人客户通知总数失败")
|
||||
@@ -194,8 +197,8 @@ func personalNotificationScope(db *gorm.DB, customerID uint, now time.Time) *gor
|
||||
AND category IN ? AND type IN ? AND (expires_at IS NULL OR expires_at > ?)`,
|
||||
constants.NotificationRecipientKindPersonalCustomer,
|
||||
customerID,
|
||||
[]string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry},
|
||||
[]string{constants.NotificationTypePackageExpiring},
|
||||
[]string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry, constants.NotificationCategorySystem},
|
||||
[]string{constants.NotificationTypePackageExpiring, constants.NotificationTypeExchangeShippingCreated},
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
372
internal/query/packageexpiry/list.go
Normal file
372
internal/query/packageexpiry/list.go
Normal file
@@ -0,0 +1,372 @@
|
||||
package packageexpiry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const expiryWindowDays = 15
|
||||
|
||||
// ListResult 是临期资产 Query 的分页结果。
|
||||
type ListResult struct {
|
||||
Items []dto.ExpiringAssetItem
|
||||
Total int64
|
||||
Page int
|
||||
Size int
|
||||
Summary dto.ExpiringAssetSummary
|
||||
}
|
||||
|
||||
type assetCandidate struct {
|
||||
AssetType string
|
||||
AssetID uint
|
||||
Identifier string
|
||||
ShopID *uint
|
||||
}
|
||||
|
||||
// List 查询当前权限范围内的临期资产,并返回同口径数量汇总。
|
||||
func (q *Query) List(ctx context.Context, request dto.ExpiringAssetListRequest) (ListResult, error) {
|
||||
if q == nil || q.db == nil {
|
||||
return ListResult{}, errors.New(errors.CodeInternalError, "套餐临期查询未配置")
|
||||
}
|
||||
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise {
|
||||
return ListResult{}, errors.New(errors.CodeForbidden, "企业账号无权查看临期资产列表")
|
||||
}
|
||||
request = normalizeListRequest(request)
|
||||
if err := validateListRequest(request); err != nil {
|
||||
return ListResult{}, err
|
||||
}
|
||||
items, err := q.collect(ctx, request)
|
||||
if err != nil {
|
||||
return ListResult{}, err
|
||||
}
|
||||
summary := summarize(items)
|
||||
total := int64(len(items))
|
||||
start := (request.Page - 1) * request.PageSize
|
||||
if start > len(items) {
|
||||
start = len(items)
|
||||
}
|
||||
end := start + request.PageSize
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
return ListResult{Items: items[start:end], Total: total, Page: request.Page, Size: request.PageSize, Summary: summary}, nil
|
||||
}
|
||||
|
||||
// ReminderCandidates 查询当天精确命中 15、7、3 天节点的全部资产,供每日通知任务复用。
|
||||
func (q *Query) ReminderCandidates(ctx context.Context) ([]dto.ExpiringAssetItem, error) {
|
||||
items, err := q.collect(ctx, normalizeListRequest(dto.ExpiringAssetListRequest{}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]dto.ExpiringAssetItem, 0)
|
||||
for _, item := range items {
|
||||
if item.DaysUntilFinalExpiry == nil {
|
||||
continue
|
||||
}
|
||||
switch *item.DaysUntilFinalExpiry {
|
||||
case 15, 7, 3:
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (q *Query) collect(ctx context.Context, request dto.ExpiringAssetListRequest) ([]dto.ExpiringAssetItem, error) {
|
||||
candidates := make([]assetCandidate, 0)
|
||||
if request.AssetType == "" || request.AssetType == constants.AssetTypeIotCard {
|
||||
cards, err := q.findCardCandidates(ctx, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidates = append(candidates, cards...)
|
||||
}
|
||||
if request.AssetType == "" || request.AssetType == constants.AssetTypeDevice {
|
||||
devices, err := q.findDeviceCandidates(ctx, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidates = append(candidates, devices...)
|
||||
}
|
||||
items, err := q.resolveCandidates(ctx, candidates, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := q.fillShopNames(ctx, items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].IsPriority != items[j].IsPriority {
|
||||
return items[i].IsPriority
|
||||
}
|
||||
left, right := items[i].EstimatedFinalExpiresAt, items[j].EstimatedFinalExpiresAt
|
||||
if left != nil && right != nil && !left.Equal(*right) {
|
||||
return left.Before(*right)
|
||||
}
|
||||
if items[i].AssetType != items[j].AssetType {
|
||||
return items[i].AssetType < items[j].AssetType
|
||||
}
|
||||
return items[i].AssetID < items[j].AssetID
|
||||
})
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (q *Query) findCardCandidates(ctx context.Context, request dto.ExpiringAssetListRequest) ([]assetCandidate, error) {
|
||||
var rows []model.IotCard
|
||||
query := q.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Select("id, iccid, shop_id")
|
||||
query = applyStrictShopScope(ctx, query)
|
||||
if request.ShopID != nil {
|
||||
query = query.Where("shop_id = ?", *request.ShopID)
|
||||
}
|
||||
if request.Keyword != "" {
|
||||
keyword := "%" + strings.TrimSpace(request.Keyword) + "%"
|
||||
query = query.Where("iccid ILIKE ? OR msisdn ILIKE ? OR virtual_no ILIKE ?", keyword, keyword, keyword)
|
||||
}
|
||||
query = query.Where(`EXISTS (
|
||||
SELECT 1 FROM tb_package_usage pu
|
||||
WHERE pu.iot_card_id = tb_iot_card.id AND pu.deleted_at IS NULL
|
||||
AND pu.master_usage_id IS NULL AND pu.refund_id IS NULL
|
||||
AND pu.status IN ? AND pu.expires_at IS NOT NULL AND pu.expires_at < ?
|
||||
)`, []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}, dateInShanghai(q.now()).AddDate(0, 0, expiryWindowDays+1))
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期卡候选失败")
|
||||
}
|
||||
result := make([]assetCandidate, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
result = append(result, assetCandidate{AssetType: constants.AssetTypeIotCard, AssetID: row.ID, Identifier: row.ICCID, ShopID: row.ShopID})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (q *Query) findDeviceCandidates(ctx context.Context, request dto.ExpiringAssetListRequest) ([]assetCandidate, error) {
|
||||
var rows []model.Device
|
||||
query := q.db.WithContext(ctx).Model(&model.Device{}).
|
||||
Select("id, virtual_no, imei, shop_id")
|
||||
query = applyStrictShopScope(ctx, query)
|
||||
if request.ShopID != nil {
|
||||
query = query.Where("shop_id = ?", *request.ShopID)
|
||||
}
|
||||
if request.Keyword != "" {
|
||||
keyword := "%" + strings.TrimSpace(request.Keyword) + "%"
|
||||
query = query.Where("virtual_no ILIKE ? OR imei ILIKE ?", keyword, keyword)
|
||||
}
|
||||
query = query.Where(`EXISTS (
|
||||
SELECT 1 FROM tb_package_usage pu
|
||||
WHERE pu.device_id = tb_device.id AND pu.deleted_at IS NULL
|
||||
AND pu.master_usage_id IS NULL AND pu.refund_id IS NULL
|
||||
AND pu.status IN ? AND pu.expires_at IS NOT NULL AND pu.expires_at < ?
|
||||
)`, []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}, dateInShanghai(q.now()).AddDate(0, 0, expiryWindowDays+1))
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期设备候选失败")
|
||||
}
|
||||
result := make([]assetCandidate, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
identifier := row.VirtualNo
|
||||
if identifier == "" {
|
||||
identifier = row.IMEI
|
||||
}
|
||||
result = append(result, assetCandidate{AssetType: constants.AssetTypeDevice, AssetID: row.ID, Identifier: identifier, ShopID: row.ShopID})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (q *Query) resolveCandidates(ctx context.Context, candidates []assetCandidate, request dto.ExpiringAssetListRequest) ([]dto.ExpiringAssetItem, error) {
|
||||
groupedIDs := map[string][]uint{constants.AssetTypeIotCard: {}, constants.AssetTypeDevice: {}}
|
||||
for _, candidate := range candidates {
|
||||
groupedIDs[candidate.AssetType] = append(groupedIDs[candidate.AssetType], candidate.AssetID)
|
||||
}
|
||||
estimates := make(map[string]map[uint]dto.PackageExpiryEstimate, 2)
|
||||
finalUsages := make(map[string]map[uint]*model.PackageUsage, 2)
|
||||
for _, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
|
||||
var err error
|
||||
estimates[assetType], err = q.ResolveBatch(ctx, assetType, groupedIDs[assetType])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
finalUsages[assetType], err = q.loadFinalUsages(ctx, assetType, groupedIDs[assetType])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
from, to, err := parseExpiryRange(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]dto.ExpiringAssetItem, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
estimate := estimates[candidate.AssetType][candidate.AssetID]
|
||||
usage := finalUsages[candidate.AssetType][candidate.AssetID]
|
||||
if usage == nil || estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact || estimate.DaysUntilFinalExpiry == nil || estimate.EstimatedFinalExpiresAt == nil {
|
||||
continue
|
||||
}
|
||||
days := *estimate.DaysUntilFinalExpiry
|
||||
if days < 0 || days > expiryWindowDays || request.DaysMin != nil && days < *request.DaysMin || request.DaysMax != nil && days > *request.DaysMax {
|
||||
continue
|
||||
}
|
||||
expiryDate := dateInShanghai(*estimate.EstimatedFinalExpiresAt)
|
||||
if from != nil && expiryDate.Before(*from) || to != nil && expiryDate.After(*to) {
|
||||
continue
|
||||
}
|
||||
if request.PackageID != nil && usage.PackageID != *request.PackageID {
|
||||
continue
|
||||
}
|
||||
level, levelName := expiryLevel(days)
|
||||
items = append(items, dto.ExpiringAssetItem{
|
||||
AssetType: candidate.AssetType, AssetID: candidate.AssetID, Identifier: candidate.Identifier, ShopID: candidate.ShopID,
|
||||
PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageName: usage.PackageName,
|
||||
PackageExpiryEstimate: estimate, ExpiryLevel: level, ExpiryLevelName: levelName, IsPriority: days <= 3,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (q *Query) loadFinalUsages(ctx context.Context, assetType string, assetIDs []uint) (map[uint]*model.PackageUsage, error) {
|
||||
result := make(map[uint]*model.PackageUsage, len(assetIDs))
|
||||
if len(assetIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
column, ok := packageUsageAssetColumn(assetType)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "资产类型无效")
|
||||
}
|
||||
var usages []*model.PackageUsage
|
||||
if err := q.db.WithContext(ctx).Where(column+" IN ?", assetIDs).
|
||||
Where("master_usage_id IS NULL AND refund_id IS NULL").
|
||||
Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||||
Order("priority ASC, created_at ASC, id ASC").Find(&usages).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期资产最终套餐失败")
|
||||
}
|
||||
for _, usage := range usages {
|
||||
assetID := usage.IotCardID
|
||||
if assetType == constants.AssetTypeDevice {
|
||||
assetID = usage.DeviceID
|
||||
}
|
||||
result[assetID] = usage
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (q *Query) fillShopNames(ctx context.Context, items []dto.ExpiringAssetItem) error {
|
||||
ids := make([]uint, 0)
|
||||
seen := make(map[uint]struct{})
|
||||
for _, item := range items {
|
||||
if item.ShopID == nil {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[*item.ShopID]; !exists {
|
||||
seen[*item.ShopID] = struct{}{}
|
||||
ids = append(ids, *item.ShopID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
var shops []model.Shop
|
||||
if err := q.db.WithContext(ctx).Select("id, shop_name").Where("id IN ?", ids).Find(&shops).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询临期资产店铺名称失败")
|
||||
}
|
||||
names := make(map[uint]string, len(shops))
|
||||
for _, shop := range shops {
|
||||
names[shop.ID] = shop.ShopName
|
||||
}
|
||||
for index := range items {
|
||||
if items[index].ShopID != nil {
|
||||
items[index].ShopName = names[*items[index].ShopID]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyStrictShopScope(ctx context.Context, query *gorm.DB) *gorm.DB {
|
||||
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
|
||||
return query
|
||||
}
|
||||
shopIDs := middleware.GetSubordinateShopIDs(ctx)
|
||||
if len(shopIDs) == 0 {
|
||||
return query.Where("1 = 0")
|
||||
}
|
||||
return query.Where("shop_id IN ?", shopIDs)
|
||||
}
|
||||
|
||||
func normalizeListRequest(request dto.ExpiringAssetListRequest) dto.ExpiringAssetListRequest {
|
||||
if request.Page <= 0 {
|
||||
request.Page = 1
|
||||
}
|
||||
if request.PageSize <= 0 {
|
||||
request.PageSize = constants.DefaultPageSize
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func validateListRequest(request dto.ExpiringAssetListRequest) error {
|
||||
if request.PageSize > constants.MaxPageSize || request.Page < 1 {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if request.AssetType != "" && request.AssetType != constants.AssetTypeIotCard && request.AssetType != constants.AssetTypeDevice {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if request.DaysMin != nil && (*request.DaysMin < 0 || *request.DaysMin > expiryWindowDays) || request.DaysMax != nil && (*request.DaysMax < 0 || *request.DaysMax > expiryWindowDays) {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if request.DaysMin != nil && request.DaysMax != nil && *request.DaysMin > *request.DaysMax {
|
||||
return errors.New(errors.CodeInvalidParam, "最小剩余天数不能大于最大剩余天数")
|
||||
}
|
||||
_, _, err := parseExpiryRange(request)
|
||||
return err
|
||||
}
|
||||
|
||||
func parseExpiryRange(request dto.ExpiringAssetListRequest) (*time.Time, *time.Time, error) {
|
||||
parse := func(value string) (*time.Time, error) {
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
result, err := time.ParseInLocation("2006-01-02", value, shanghaiLocation)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "到期日期格式无效")
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
from, err := parse(request.ExpiresFrom)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
to, err := parse(request.ExpiresTo)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if from != nil && to != nil && from.After(*to) {
|
||||
return nil, nil, errors.New(errors.CodeInvalidParam, "到期开始日期不能晚于结束日期")
|
||||
}
|
||||
return from, to, nil
|
||||
}
|
||||
|
||||
func expiryLevel(days int) (string, string) {
|
||||
if days <= 3 {
|
||||
return "red", "红色"
|
||||
}
|
||||
if days <= 7 {
|
||||
return "purple", "紫色"
|
||||
}
|
||||
return "pink", "粉红色"
|
||||
}
|
||||
|
||||
func summarize(items []dto.ExpiringAssetItem) dto.ExpiringAssetSummary {
|
||||
result := dto.ExpiringAssetSummary{WindowDays: expiryWindowDays, TotalCount: int64(len(items))}
|
||||
for _, item := range items {
|
||||
if item.AssetType == constants.AssetTypeIotCard {
|
||||
result.CardCount++
|
||||
} else if item.AssetType == constants.AssetTypeDevice {
|
||||
result.DeviceCount++
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -139,7 +139,8 @@ func (q *BusinessOwnerQuery) project(ctx context.Context, shops []*model.Shop) (
|
||||
BusinessOwnerAccountID: shop.BusinessOwnerAccountID, Level: shop.Level,
|
||||
ContactName: shop.ContactName, ContactPhone: shop.ContactPhone, Province: shop.Province,
|
||||
City: shop.City, District: shop.District, Address: shop.Address, Status: shop.Status,
|
||||
StatusName: constants.GetStatusName(shop.Status), CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
ClientLoginDisabled: shop.ClientLoginDisabled,
|
||||
StatusName: constants.GetStatusName(shop.Status), CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
if shop.ParentID != nil {
|
||||
|
||||
Reference in New Issue
Block a user