From 194078674a2e872e66790987eb61d457b770afb4 Mon Sep 17 00:00:00 2001 From: huang Date: Sat, 24 Jan 2026 15:46:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=8D=95=E5=8D=A1?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E5=88=86=E9=85=8D=E4=B8=8E=E5=9B=9E=E6=94=B6?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增单卡分配/回收 API(支持 ICCID 列表、号段范围、筛选条件三种选卡方式) - 新增资产分配记录查询 API(支持多条件筛选和分页) - 新增 AssetAllocationRecord 模型、Store、Service、Handler 完整实现 - 扩展 IotCardStore 新增批量更新、号段查询、筛选查询等方法 - 修复 GORM Callback 处理 slice 类型(BatchCreate)的问题 - 新增完整的单元测试和集成测试 - 同步 OpenSpec 规范并归档 change --- .gitignore | 1 + cmd/api/docs.go | 1 + cmd/gendocs/main.go | 1 + internal/bootstrap/handlers.go | 1 + internal/bootstrap/services.go | 5 +- internal/bootstrap/stores.go | 2 + internal/bootstrap/types.go | 1 + .../handler/admin/asset_allocation_record.go | 58 +++ internal/handler/admin/iot_card.go | 54 +++ internal/model/asset_allocation_record.go | 2 +- .../model/dto/asset_allocation_record_dto.go | 62 +++ .../dto/standalone_card_allocation_dto.go | 108 +++++ internal/routes/admin.go | 3 + internal/routes/asset_allocation_record.go | 30 ++ internal/routes/iot_card.go | 16 + .../asset_allocation_record/service.go | 253 +++++++++++ internal/service/iot_card/service.go | 370 ++++++++++++++- .../postgres/asset_allocation_record_store.go | 127 ++++++ .../asset_allocation_record_store_test.go | 232 ++++++++++ internal/store/postgres/iot_card_store.go | 102 +++-- .../store/postgres/iot_card_store_test.go | 171 +++++++ ...ate_asset_allocation_record_table.down.sql | 1 + ...reate_asset_allocation_record_table.up.sql | 47 ++ .../proposal.md | 82 ++++ .../specs/asset-allocation-record/spec.md | 101 +++++ .../specs/iot-card/spec.md | 129 ++++++ .../tasks.md | 76 ++++ .../specs/asset-allocation-record/spec.md | 107 +++++ openspec/specs/iot-card/spec.md | 130 ++++++ pkg/errors/codes.go | 121 +++-- pkg/errors/errors.go | 10 + pkg/gorm/callback.go | 47 +- .../standalone_card_allocation_test.go | 426 ++++++++++++++++++ 33 files changed, 2785 insertions(+), 92 deletions(-) create mode 100644 internal/handler/admin/asset_allocation_record.go create mode 100644 internal/model/dto/asset_allocation_record_dto.go create mode 100644 internal/model/dto/standalone_card_allocation_dto.go create mode 100644 internal/routes/asset_allocation_record.go create mode 100644 internal/service/asset_allocation_record/service.go create mode 100644 internal/store/postgres/asset_allocation_record_store.go create mode 100644 internal/store/postgres/asset_allocation_record_store_test.go create mode 100644 migrations/000014_create_asset_allocation_record_table.down.sql create mode 100644 migrations/000014_create_asset_allocation_record_table.up.sql create mode 100644 openspec/changes/archive/2026-01-24-add-standalone-card-allocation/proposal.md create mode 100644 openspec/changes/archive/2026-01-24-add-standalone-card-allocation/specs/asset-allocation-record/spec.md create mode 100644 openspec/changes/archive/2026-01-24-add-standalone-card-allocation/specs/iot-card/spec.md create mode 100644 openspec/changes/archive/2026-01-24-add-standalone-card-allocation/tasks.md create mode 100644 openspec/specs/asset-allocation-record/spec.md create mode 100644 tests/integration/standalone_card_allocation_test.go diff --git a/.gitignore b/.gitignore index f6bc95e..57cd801 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,4 @@ ai-gateway.conf __debug_bin1621385388 docs/admin-openapi.yaml /api +/gendocs diff --git a/cmd/api/docs.go b/cmd/api/docs.go index 28ddffb..13185e3 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -40,6 +40,7 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) { MyCommission: admin.NewMyCommissionHandler(nil), IotCard: admin.NewIotCardHandler(nil), IotCardImport: admin.NewIotCardImportHandler(nil), + AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil), } // 4. 注册所有路由到文档生成器 diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index 4404847..6d4a41d 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -49,6 +49,7 @@ func generateAdminDocs(outputPath string) error { MyCommission: admin.NewMyCommissionHandler(nil), IotCard: admin.NewIotCardHandler(nil), IotCardImport: admin.NewIotCardImportHandler(nil), + AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil), } // 4. 注册所有路由到文档生成器 diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 6ae3568..faed24c 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -28,5 +28,6 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { MyCommission: admin.NewMyCommissionHandler(svc.MyCommission), IotCard: admin.NewIotCardHandler(svc.IotCard), IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport), + AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord), } } diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index cd2caa2..13f74fc 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -2,6 +2,7 @@ package bootstrap import ( accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account" + assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record" authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth" commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal" commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting" @@ -36,6 +37,7 @@ type services struct { MyCommission *myCommissionSvc.Service IotCard *iotCardSvc.Service IotCardImport *iotCardImportSvc.Service + AssetAllocationRecord *assetAllocationRecordSvc.Service } func initServices(s *stores, deps *Dependencies) *services { @@ -54,7 +56,8 @@ func initServices(s *stores, deps *Dependencies) *services { EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization), CustomerAccount: customerAccountSvc.New(deps.DB, s.Account, s.Shop, s.Enterprise), MyCommission: myCommissionSvc.New(deps.DB, s.Shop, s.Wallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.WalletTransaction), - IotCard: iotCardSvc.New(deps.DB, s.IotCard), + IotCard: iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord), IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient), + AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account), } } diff --git a/internal/bootstrap/stores.go b/internal/bootstrap/stores.go index db87f1c..fb575c4 100644 --- a/internal/bootstrap/stores.go +++ b/internal/bootstrap/stores.go @@ -22,6 +22,7 @@ type stores struct { EnterpriseCardAuthorization *postgres.EnterpriseCardAuthorizationStore IotCard *postgres.IotCardStore IotCardImportTask *postgres.IotCardImportTaskStore + AssetAllocationRecord *postgres.AssetAllocationRecordStore } func initStores(deps *Dependencies) *stores { @@ -43,5 +44,6 @@ func initStores(deps *Dependencies) *stores { EnterpriseCardAuthorization: postgres.NewEnterpriseCardAuthorizationStore(deps.DB, deps.Redis), IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis), IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis), + AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis), } } diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index da831d7..ca25dad 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -26,6 +26,7 @@ type Handlers struct { MyCommission *admin.MyCommissionHandler IotCard *admin.IotCardHandler IotCardImport *admin.IotCardImportHandler + AssetAllocationRecord *admin.AssetAllocationRecordHandler } // Middlewares 封装所有中间件 diff --git a/internal/handler/admin/asset_allocation_record.go b/internal/handler/admin/asset_allocation_record.go new file mode 100644 index 0000000..de42e6d --- /dev/null +++ b/internal/handler/admin/asset_allocation_record.go @@ -0,0 +1,58 @@ +package admin + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/model/dto" + assetAllocationRecordService "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +type AssetAllocationRecordHandler struct { + service *assetAllocationRecordService.Service +} + +func NewAssetAllocationRecordHandler(service *assetAllocationRecordService.Service) *AssetAllocationRecordHandler { + return &AssetAllocationRecordHandler{service: service} +} + +func (h *AssetAllocationRecordHandler) List(c *fiber.Ctx) error { + var req dto.ListAssetAllocationRecordRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + + ctx := c.UserContext() + userType := middleware.GetUserTypeFromContext(ctx) + var userShopID *uint + if userType == constants.UserTypeAgent { + shopID := middleware.GetShopIDFromContext(ctx) + if shopID > 0 { + userShopID = &shopID + } + } + + result, err := h.service.List(ctx, &req, userShopID) + if err != nil { + return err + } + + return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize) +} + +func (h *AssetAllocationRecordHandler) GetByID(c *fiber.Ctx) error { + var req dto.GetAssetAllocationRecordRequest + if err := c.ParamsParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + + result, err := h.service.GetByID(c.UserContext(), req.ID) + if err != nil { + return err + } + + return response.Success(c, result) +} diff --git a/internal/handler/admin/iot_card.go b/internal/handler/admin/iot_card.go index 098e6b3..df9319f 100644 --- a/internal/handler/admin/iot_card.go +++ b/internal/handler/admin/iot_card.go @@ -5,7 +5,9 @@ import ( "github.com/break/junhong_cmp_fiber/internal/model/dto" iotCardService "github.com/break/junhong_cmp_fiber/internal/service/iot_card" + "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/break/junhong_cmp_fiber/pkg/response" ) @@ -30,3 +32,55 @@ func (h *IotCardHandler) ListStandalone(c *fiber.Ctx) error { return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize) } + +func (h *IotCardHandler) AllocateCards(c *fiber.Ctx) error { + var req dto.AllocateStandaloneCardsRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + + ctx := c.UserContext() + operatorID := middleware.GetUserIDFromContext(ctx) + userType := middleware.GetUserTypeFromContext(ctx) + + var operatorShopID *uint + if userType == constants.UserTypeAgent { + shopID := middleware.GetShopIDFromContext(ctx) + if shopID > 0 { + operatorShopID = &shopID + } + } + + result, err := h.service.AllocateCards(ctx, &req, operatorID, operatorShopID) + if err != nil { + return err + } + + return response.Success(c, result) +} + +func (h *IotCardHandler) RecallCards(c *fiber.Ctx) error { + var req dto.RecallStandaloneCardsRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + + ctx := c.UserContext() + operatorID := middleware.GetUserIDFromContext(ctx) + userType := middleware.GetUserTypeFromContext(ctx) + + var operatorShopID *uint + if userType == constants.UserTypeAgent { + shopID := middleware.GetShopIDFromContext(ctx) + if shopID > 0 { + operatorShopID = &shopID + } + } + + result, err := h.service.RecallCards(ctx, &req, operatorID, operatorShopID) + if err != nil { + return err + } + + return response.Success(c, result) +} diff --git a/internal/model/asset_allocation_record.go b/internal/model/asset_allocation_record.go index 59d7c7a..58a3352 100644 --- a/internal/model/asset_allocation_record.go +++ b/internal/model/asset_allocation_record.go @@ -10,7 +10,7 @@ import ( type AssetAllocationRecord struct { gorm.Model BaseModel `gorm:"embedded"` - AllocationNo string `gorm:"column:allocation_no;type:varchar(50);uniqueIndex:uk_asset_allocation_no,where:deleted_at IS NULL;not null;comment:分配单号(唯一)" json:"allocation_no"` + AllocationNo string `gorm:"column:allocation_no;type:varchar(50);index;not null;comment:分配单号(同批次相同)" json:"allocation_no"` AllocationType string `gorm:"column:allocation_type;type:varchar(20);index;not null;comment:分配类型 allocate=分配 recall=回收" json:"allocation_type"` AssetType string `gorm:"column:asset_type;type:varchar(20);index;not null;comment:资产类型 iot_card=物联网卡 device=设备" json:"asset_type"` AssetID uint `gorm:"column:asset_id;index;not null;comment:资产ID" json:"asset_id"` diff --git a/internal/model/dto/asset_allocation_record_dto.go b/internal/model/dto/asset_allocation_record_dto.go new file mode 100644 index 0000000..29cceec --- /dev/null +++ b/internal/model/dto/asset_allocation_record_dto.go @@ -0,0 +1,62 @@ +package dto + +import "time" + +// ListAssetAllocationRecordRequest 分配记录列表请求 +type ListAssetAllocationRecordRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` + AllocationType string `json:"allocation_type" query:"allocation_type" validate:"omitempty,oneof=allocate recall" enum:"allocate,recall" description:"分配类型 (allocate:分配, recall:回收)"` + AssetType string `json:"asset_type" query:"asset_type" validate:"omitempty,oneof=iot_card device" enum:"iot_card,device" description:"资产类型 (iot_card:物联网卡, device:设备)"` + AssetIdentifier string `json:"asset_identifier" query:"asset_identifier" validate:"omitempty,max=50" maxLength:"50" description:"资产标识符(ICCID或设备号,模糊查询)"` + AllocationNo string `json:"allocation_no" query:"allocation_no" validate:"omitempty,max=50" maxLength:"50" description:"分配单号(精确匹配)"` + FromShopID *uint `json:"from_shop_id" query:"from_shop_id" description:"来源店铺ID"` + ToShopID *uint `json:"to_shop_id" query:"to_shop_id" description:"目标店铺ID"` + OperatorID *uint `json:"operator_id" query:"operator_id" description:"操作人ID"` + CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"` + CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"` +} + +// AssetAllocationRecordResponse 分配记录响应 +type AssetAllocationRecordResponse struct { + ID uint `json:"id" description:"记录ID"` + AllocationNo string `json:"allocation_no" description:"分配单号"` + AllocationType string `json:"allocation_type" description:"分配类型 (allocate:分配, recall:回收)"` + AllocationName string `json:"allocation_name" description:"分配类型名称"` + AssetType string `json:"asset_type" description:"资产类型 (iot_card:物联网卡, device:设备)"` + AssetTypeName string `json:"asset_type_name" description:"资产类型名称"` + AssetID uint `json:"asset_id" description:"资产ID"` + AssetIdentifier string `json:"asset_identifier" description:"资产标识符(ICCID或设备号)"` + FromOwnerType string `json:"from_owner_type" description:"来源所有者类型"` + FromOwnerID *uint `json:"from_owner_id,omitempty" description:"来源所有者ID"` + FromOwnerName string `json:"from_owner_name" description:"来源所有者名称"` + ToOwnerType string `json:"to_owner_type" description:"目标所有者类型"` + ToOwnerID uint `json:"to_owner_id" description:"目标所有者ID"` + ToOwnerName string `json:"to_owner_name" description:"目标所有者名称"` + OperatorID uint `json:"operator_id" description:"操作人ID"` + OperatorName string `json:"operator_name" description:"操作人名称"` + Remark string `json:"remark,omitempty" description:"备注"` + RelatedDeviceID *uint `json:"related_device_id,omitempty" description:"关联设备ID"` + RelatedCardCount int `json:"related_card_count,omitempty" description:"关联卡数量"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` +} + +// ListAssetAllocationRecordResponse 分配记录列表响应 +type ListAssetAllocationRecordResponse struct { + List []*AssetAllocationRecordResponse `json:"list" description:"分配记录列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"page_size" description:"每页数量"` + TotalPages int `json:"total_pages" description:"总页数"` +} + +// GetAssetAllocationRecordRequest 获取分配记录详情请求 +type GetAssetAllocationRecordRequest struct { + ID uint `json:"-" params:"id" path:"id" validate:"required,min=1" required:"true" minimum:"1" description:"记录ID"` +} + +// AssetAllocationRecordDetailResponse 分配记录详情响应 +type AssetAllocationRecordDetailResponse struct { + AssetAllocationRecordResponse + RelatedCardIDs []uint `json:"related_card_ids,omitempty" description:"关联卡ID列表"` +} diff --git a/internal/model/dto/standalone_card_allocation_dto.go b/internal/model/dto/standalone_card_allocation_dto.go new file mode 100644 index 0000000..91e77ed --- /dev/null +++ b/internal/model/dto/standalone_card_allocation_dto.go @@ -0,0 +1,108 @@ +package dto + +// ========== 选卡方式常量 ========== + +const ( + // SelectionTypeList ICCID列表选择 + SelectionTypeList = "list" + // SelectionTypeRange 号段范围选择 + SelectionTypeRange = "range" + // SelectionTypeFilter 筛选条件选择 + SelectionTypeFilter = "filter" +) + +// ========== 分配请求/响应 ========== + +// AllocateStandaloneCardsRequest 分配单卡请求 +type AllocateStandaloneCardsRequest struct { + // ToShopID 目标店铺ID(必填,必须是直属下级) + ToShopID uint `json:"to_shop_id" validate:"required,min=1" required:"true" minimum:"1" description:"目标店铺ID"` + + // SelectionType 选卡方式(必填) + // list: ICCID列表选择 + // range: 号段范围选择 + // filter: 筛选条件选择 + SelectionType string `json:"selection_type" validate:"required,oneof=list range filter" required:"true" enum:"list,range,filter" description:"选卡方式 (list:ICCID列表, range:号段范围, filter:筛选条件)"` + + // ===== selection_type=list 时使用 ===== + // ICCIDs ICCID列表(最多1000个) + ICCIDs []string `json:"iccids" validate:"required_if=SelectionType list,omitempty,max=1000,dive,required,max=20" description:"ICCID列表(selection_type=list时必填,最多1000个)"` + + // ===== selection_type=range 时使用 ===== + // ICCIDStart 起始ICCID + ICCIDStart string `json:"iccid_start" validate:"required_if=SelectionType range,omitempty,max=20" maxLength:"20" description:"起始ICCID(selection_type=range时必填)"` + // ICCIDEnd 结束ICCID + ICCIDEnd string `json:"iccid_end" validate:"required_if=SelectionType range,omitempty,max=20" maxLength:"20" description:"结束ICCID(selection_type=range时必填)"` + + // ===== selection_type=filter 时使用 ===== + // CarrierID 运营商ID + CarrierID *uint `json:"carrier_id" description:"运营商ID(selection_type=filter时可选)"` + // BatchNo 批次号 + BatchNo string `json:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号(selection_type=filter时可选)"` + // Status 卡状态 + Status *int `json:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"卡状态 (1:在库, 2:已分销)(selection_type=filter时可选)"` + + // Remark 备注 + Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注"` +} + +// AllocateStandaloneCardsResponse 分配单卡响应 +type AllocateStandaloneCardsResponse struct { + // TotalCount 待分配总数 + TotalCount int `json:"total_count" description:"待分配总数"` + // SuccessCount 成功数 + SuccessCount int `json:"success_count" description:"成功数"` + // FailCount 失败数 + FailCount int `json:"fail_count" description:"失败数"` + // AllocationNo 分配单号 + AllocationNo string `json:"allocation_no" description:"分配单号"` + // FailedItems 失败项列表 + FailedItems []AllocationFailedItem `json:"failed_items" description:"失败项列表"` +} + +// AllocationFailedItem 分配失败项 +type AllocationFailedItem struct { + // ICCID 卡ICCID + ICCID string `json:"iccid" description:"ICCID"` + // Reason 失败原因 + Reason string `json:"reason" description:"失败原因"` +} + +// ========== 回收请求/响应 ========== + +// RecallStandaloneCardsRequest 回收单卡请求 +type RecallStandaloneCardsRequest struct { + // FromShopID 来源店铺ID(必填,被回收方,必须是直属下级) + FromShopID uint `json:"from_shop_id" validate:"required,min=1" required:"true" minimum:"1" description:"来源店铺ID(被回收方)"` + + // SelectionType 选卡方式(必填) + SelectionType string `json:"selection_type" validate:"required,oneof=list range filter" required:"true" enum:"list,range,filter" description:"选卡方式 (list:ICCID列表, range:号段范围, filter:筛选条件)"` + + // ===== selection_type=list 时使用 ===== + ICCIDs []string `json:"iccids" validate:"required_if=SelectionType list,omitempty,max=1000,dive,required,max=20" description:"ICCID列表(selection_type=list时必填,最多1000个)"` + + // ===== selection_type=range 时使用 ===== + ICCIDStart string `json:"iccid_start" validate:"required_if=SelectionType range,omitempty,max=20" maxLength:"20" description:"起始ICCID(selection_type=range时必填)"` + ICCIDEnd string `json:"iccid_end" validate:"required_if=SelectionType range,omitempty,max=20" maxLength:"20" description:"结束ICCID(selection_type=range时必填)"` + + // ===== selection_type=filter 时使用 ===== + CarrierID *uint `json:"carrier_id" description:"运营商ID(selection_type=filter时可选)"` + BatchNo string `json:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号(selection_type=filter时可选)"` + + // Remark 备注 + Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注"` +} + +// RecallStandaloneCardsResponse 回收单卡响应 +type RecallStandaloneCardsResponse struct { + // TotalCount 待回收总数 + TotalCount int `json:"total_count" description:"待回收总数"` + // SuccessCount 成功数 + SuccessCount int `json:"success_count" description:"成功数"` + // FailCount 失败数 + FailCount int `json:"fail_count" description:"失败数"` + // AllocationNo 回收单号 + AllocationNo string `json:"allocation_no" description:"回收单号"` + // FailedItems 失败项列表 + FailedItems []AllocationFailedItem `json:"failed_items" description:"失败项列表"` +} diff --git a/internal/routes/admin.go b/internal/routes/admin.go index ac7e8ca..3e1bec8 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -55,6 +55,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.IotCard != nil { registerIotCardRoutes(authGroup, handlers.IotCard, handlers.IotCardImport, doc, basePath) } + if handlers.AssetAllocationRecord != nil { + registerAssetAllocationRecordRoutes(authGroup, handlers.AssetAllocationRecord, doc, basePath) + } } func registerAdminAuthRoutes(router fiber.Router, handler interface{}, authMiddleware fiber.Handler, doc *openapi.Generator, basePath string) { diff --git a/internal/routes/asset_allocation_record.go b/internal/routes/asset_allocation_record.go new file mode 100644 index 0000000..ac2c68b --- /dev/null +++ b/internal/routes/asset_allocation_record.go @@ -0,0 +1,30 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/handler/admin" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/openapi" +) + +func registerAssetAllocationRecordRoutes(router fiber.Router, handler *admin.AssetAllocationRecordHandler, doc *openapi.Generator, basePath string) { + records := router.Group("/asset-allocation-records") + groupPath := basePath + "/asset-allocation-records" + + Register(records, doc, groupPath, "GET", "", handler.List, RouteSpec{ + Summary: "分配记录列表", + Tags: []string{"资产分配记录"}, + Input: new(dto.ListAssetAllocationRecordRequest), + Output: new(dto.ListAssetAllocationRecordResponse), + Auth: true, + }) + + Register(records, doc, groupPath, "GET", "/:id", handler.GetByID, RouteSpec{ + Summary: "分配记录详情", + Tags: []string{"资产分配记录"}, + Input: new(dto.GetAssetAllocationRecordRequest), + Output: new(dto.AssetAllocationRecordDetailResponse), + Auth: true, + }) +} diff --git a/internal/routes/iot_card.go b/internal/routes/iot_card.go index e9a5f65..8fa0fa3 100644 --- a/internal/routes/iot_card.go +++ b/internal/routes/iot_card.go @@ -43,4 +43,20 @@ func registerIotCardRoutes(router fiber.Router, handler *admin.IotCardHandler, i Output: new(dto.ImportTaskDetailResponse), Auth: true, }) + + Register(iotCards, doc, groupPath, "POST", "/standalone/allocate", handler.AllocateCards, RouteSpec{ + Summary: "批量分配单卡", + Tags: []string{"IoT卡管理"}, + Input: new(dto.AllocateStandaloneCardsRequest), + Output: new(dto.AllocateStandaloneCardsResponse), + Auth: true, + }) + + Register(iotCards, doc, groupPath, "POST", "/standalone/recall", handler.RecallCards, RouteSpec{ + Summary: "批量回收单卡", + Tags: []string{"IoT卡管理"}, + Input: new(dto.RecallStandaloneCardsRequest), + Output: new(dto.RecallStandaloneCardsResponse), + Auth: true, + }) } diff --git a/internal/service/asset_allocation_record/service.go b/internal/service/asset_allocation_record/service.go new file mode 100644 index 0000000..05300e9 --- /dev/null +++ b/internal/service/asset_allocation_record/service.go @@ -0,0 +1,253 @@ +package asset_allocation_record + +import ( + "context" + "encoding/json" + + "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" + "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" + "gorm.io/gorm" +) + +type Service struct { + db *gorm.DB + assetAllocationRecordStore *postgres.AssetAllocationRecordStore + shopStore *postgres.ShopStore + accountStore *postgres.AccountStore +} + +func New( + db *gorm.DB, + assetAllocationRecordStore *postgres.AssetAllocationRecordStore, + shopStore *postgres.ShopStore, + accountStore *postgres.AccountStore, +) *Service { + return &Service{ + db: db, + assetAllocationRecordStore: assetAllocationRecordStore, + shopStore: shopStore, + accountStore: accountStore, + } +} + +func (s *Service) List(ctx context.Context, req *dto.ListAssetAllocationRecordRequest, userShopID *uint) (*dto.ListAssetAllocationRecordResponse, error) { + page := req.Page + pageSize := req.PageSize + if page == 0 { + page = 1 + } + if pageSize == 0 { + pageSize = constants.DefaultPageSize + } + + opts := &store.QueryOptions{ + Page: page, + PageSize: pageSize, + } + + filters := make(map[string]any) + if req.AllocationType != "" { + filters["allocation_type"] = req.AllocationType + } + if req.AssetType != "" { + filters["asset_type"] = req.AssetType + } + if req.AssetIdentifier != "" { + filters["asset_identifier"] = req.AssetIdentifier + } + if req.AllocationNo != "" { + filters["allocation_no"] = req.AllocationNo + } + if req.FromShopID != nil { + filters["from_shop_id"] = *req.FromShopID + } + if req.ToShopID != nil { + filters["to_shop_id"] = *req.ToShopID + } + if req.OperatorID != nil { + filters["operator_id"] = *req.OperatorID + } + if req.CreatedAtStart != nil { + filters["created_at_start"] = *req.CreatedAtStart + } + if req.CreatedAtEnd != nil { + filters["created_at_end"] = *req.CreatedAtEnd + } + + if userShopID != nil { + subordinateIDs, err := s.shopStore.GetSubordinateShopIDs(ctx, *userShopID) + if err != nil { + return nil, err + } + filters["related_shop_ids"] = subordinateIDs + } + + records, total, err := s.assetAllocationRecordStore.List(ctx, opts, filters) + if err != nil { + return nil, err + } + + shopIDs, operatorIDs := s.collectRelatedIDs(records) + shopMap := s.loadShopNames(ctx, shopIDs) + operatorMap := s.loadOperatorNames(ctx, operatorIDs) + + list := make([]*dto.AssetAllocationRecordResponse, 0, len(records)) + for _, record := range records { + item := s.toResponse(record, shopMap, operatorMap) + list = append(list, item) + } + + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + + return &dto.ListAssetAllocationRecordResponse{ + List: list, + Total: total, + Page: page, + PageSize: pageSize, + TotalPages: totalPages, + }, nil +} + +func (s *Service) GetByID(ctx context.Context, id uint) (*dto.AssetAllocationRecordDetailResponse, error) { + record, err := s.assetAllocationRecordStore.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.ErrAssetAllocationRecordNotFound + } + return nil, err + } + + shopIDs, operatorIDs := s.collectRelatedIDs([]*model.AssetAllocationRecord{record}) + shopMap := s.loadShopNames(ctx, shopIDs) + operatorMap := s.loadOperatorNames(ctx, operatorIDs) + + resp := s.toResponse(record, shopMap, operatorMap) + detail := &dto.AssetAllocationRecordDetailResponse{ + AssetAllocationRecordResponse: *resp, + } + + if record.RelatedCardIDs != nil { + var cardIDs []uint + if err := json.Unmarshal(record.RelatedCardIDs, &cardIDs); err == nil { + detail.RelatedCardIDs = cardIDs + } + } + + return detail, nil +} + +func (s *Service) collectRelatedIDs(records []*model.AssetAllocationRecord) ([]uint, []uint) { + shopIDSet := make(map[uint]bool) + operatorIDSet := make(map[uint]bool) + + for _, record := range records { + if record.FromOwnerType == constants.OwnerTypeShop && record.FromOwnerID != nil { + shopIDSet[*record.FromOwnerID] = true + } + if record.ToOwnerType == constants.OwnerTypeShop { + shopIDSet[record.ToOwnerID] = true + } + operatorIDSet[record.OperatorID] = true + } + + shopIDs := make([]uint, 0, len(shopIDSet)) + for id := range shopIDSet { + shopIDs = append(shopIDs, id) + } + + operatorIDs := make([]uint, 0, len(operatorIDSet)) + for id := range operatorIDSet { + operatorIDs = append(operatorIDs, id) + } + + return shopIDs, operatorIDs +} + +func (s *Service) loadShopNames(ctx context.Context, shopIDs []uint) map[uint]string { + result := make(map[uint]string) + if len(shopIDs) == 0 { + return result + } + + var shops []model.Shop + s.db.WithContext(ctx).Where("id IN ?", shopIDs).Find(&shops) + for _, shop := range shops { + result[shop.ID] = shop.ShopName + } + return result +} + +func (s *Service) loadOperatorNames(ctx context.Context, operatorIDs []uint) map[uint]string { + result := make(map[uint]string) + if len(operatorIDs) == 0 { + return result + } + + var accounts []model.Account + s.db.WithContext(ctx).Where("id IN ?", operatorIDs).Find(&accounts) + for _, account := range accounts { + result[account.ID] = account.Username + } + return result +} + +func (s *Service) toResponse(record *model.AssetAllocationRecord, shopMap map[uint]string, operatorMap map[uint]string) *dto.AssetAllocationRecordResponse { + resp := &dto.AssetAllocationRecordResponse{ + ID: record.ID, + AllocationNo: record.AllocationNo, + AllocationType: record.AllocationType, + AssetType: record.AssetType, + AssetID: record.AssetID, + AssetIdentifier: record.AssetIdentifier, + FromOwnerType: record.FromOwnerType, + FromOwnerID: record.FromOwnerID, + ToOwnerType: record.ToOwnerType, + ToOwnerID: record.ToOwnerID, + OperatorID: record.OperatorID, + Remark: record.Remark, + RelatedDeviceID: record.RelatedDeviceID, + CreatedAt: record.CreatedAt, + } + + if record.AllocationType == constants.AssetAllocationTypeAllocate { + resp.AllocationName = "分配" + } else { + resp.AllocationName = "回收" + } + + if record.AssetType == constants.AssetTypeIotCard { + resp.AssetTypeName = "物联网卡" + } else { + resp.AssetTypeName = "设备" + } + + if record.FromOwnerType == constants.OwnerTypePlatform { + resp.FromOwnerName = "平台" + } else if record.FromOwnerID != nil { + resp.FromOwnerName = shopMap[*record.FromOwnerID] + } + + if record.ToOwnerType == constants.OwnerTypePlatform { + resp.ToOwnerName = "平台" + } else { + resp.ToOwnerName = shopMap[record.ToOwnerID] + } + + resp.OperatorName = operatorMap[record.OperatorID] + + if record.RelatedCardIDs != nil { + var cardIDs []uint + if err := json.Unmarshal(record.RelatedCardIDs, &cardIDs); err == nil { + resp.RelatedCardCount = len(cardIDs) + } + } + + return resp +} diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index 17796bb..dd79403 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -8,18 +8,28 @@ import ( "github.com/break/junhong_cmp_fiber/internal/store" "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" "gorm.io/gorm" ) type Service struct { - db *gorm.DB - iotCardStore *postgres.IotCardStore + db *gorm.DB + iotCardStore *postgres.IotCardStore + shopStore *postgres.ShopStore + assetAllocationRecordStore *postgres.AssetAllocationRecordStore } -func New(db *gorm.DB, iotCardStore *postgres.IotCardStore) *Service { +func New( + db *gorm.DB, + iotCardStore *postgres.IotCardStore, + shopStore *postgres.ShopStore, + assetAllocationRecordStore *postgres.AssetAllocationRecordStore, +) *Service { return &Service{ - db: db, - iotCardStore: iotCardStore, + db: db, + iotCardStore: iotCardStore, + shopStore: shopStore, + assetAllocationRecordStore: assetAllocationRecordStore, } } @@ -169,3 +179,353 @@ func (s *Service) toStandaloneResponse(card *model.IotCard, carrierMap map[uint] return resp } + +func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandaloneCardsRequest, operatorID uint, operatorShopID *uint) (*dto.AllocateStandaloneCardsResponse, error) { + if err := s.validateDirectSubordinate(ctx, operatorShopID, req.ToShopID); err != nil { + return nil, err + } + + cards, err := s.getCardsForAllocation(ctx, req, operatorShopID) + if err != nil { + return nil, err + } + + if len(cards) == 0 { + return &dto.AllocateStandaloneCardsResponse{ + TotalCount: 0, + SuccessCount: 0, + FailCount: 0, + FailedItems: []dto.AllocationFailedItem{}, + }, nil + } + + var cardIDs []uint + var failedItems []dto.AllocationFailedItem + + boundCardIDs, err := s.iotCardStore.GetBoundCardIDs(ctx, s.extractCardIDs(cards)) + if err != nil { + return nil, err + } + boundCardIDSet := make(map[uint]bool) + for _, id := range boundCardIDs { + boundCardIDSet[id] = true + } + + isPlatform := operatorShopID == nil + + for _, card := range cards { + if boundCardIDSet[card.ID] { + failedItems = append(failedItems, dto.AllocationFailedItem{ + ICCID: card.ICCID, + Reason: "已绑定设备的卡不能单独分配", + }) + continue + } + + if isPlatform && card.Status != constants.IotCardStatusInStock { + failedItems = append(failedItems, dto.AllocationFailedItem{ + ICCID: card.ICCID, + Reason: "平台只能分配在库状态的卡", + }) + continue + } + + if !isPlatform && card.Status != constants.IotCardStatusDistributed { + failedItems = append(failedItems, dto.AllocationFailedItem{ + ICCID: card.ICCID, + Reason: "代理只能分配已分销状态的卡", + }) + continue + } + + cardIDs = append(cardIDs, card.ID) + } + + if len(cardIDs) == 0 { + return &dto.AllocateStandaloneCardsResponse{ + TotalCount: len(cards), + SuccessCount: 0, + FailCount: len(failedItems), + FailedItems: failedItems, + }, nil + } + + newStatus := constants.IotCardStatusDistributed + toShopID := req.ToShopID + + err = s.db.Transaction(func(tx *gorm.DB) error { + txIotCardStore := postgres.NewIotCardStore(tx, nil) + txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil) + + if err := txIotCardStore.BatchUpdateShopIDAndStatus(ctx, cardIDs, &toShopID, newStatus); err != nil { + return err + } + + allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate) + records := s.buildAllocationRecords(cards, cardIDs, operatorShopID, toShopID, operatorID, allocationNo, req.Remark) + return txRecordStore.BatchCreate(ctx, records) + }) + + if err != nil { + return nil, err + } + + return &dto.AllocateStandaloneCardsResponse{ + TotalCount: len(cards), + SuccessCount: len(cardIDs), + FailCount: len(failedItems), + AllocationNo: s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate), + FailedItems: failedItems, + }, nil +} + +func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCardsRequest, operatorID uint, operatorShopID *uint) (*dto.RecallStandaloneCardsResponse, error) { + if err := s.validateDirectSubordinate(ctx, operatorShopID, req.FromShopID); err != nil { + return nil, err + } + + cards, err := s.getCardsForRecall(ctx, req) + if err != nil { + return nil, err + } + + if len(cards) == 0 { + return &dto.RecallStandaloneCardsResponse{ + TotalCount: 0, + SuccessCount: 0, + FailCount: 0, + FailedItems: []dto.AllocationFailedItem{}, + }, nil + } + + var cardIDs []uint + var failedItems []dto.AllocationFailedItem + + boundCardIDs, err := s.iotCardStore.GetBoundCardIDs(ctx, s.extractCardIDs(cards)) + if err != nil { + return nil, err + } + boundCardIDSet := make(map[uint]bool) + for _, id := range boundCardIDs { + boundCardIDSet[id] = true + } + + for _, card := range cards { + if boundCardIDSet[card.ID] { + failedItems = append(failedItems, dto.AllocationFailedItem{ + ICCID: card.ICCID, + Reason: "已绑定设备的卡不能单独回收", + }) + continue + } + + if card.ShopID == nil || *card.ShopID != req.FromShopID { + failedItems = append(failedItems, dto.AllocationFailedItem{ + ICCID: card.ICCID, + Reason: "卡不属于指定的店铺", + }) + continue + } + + cardIDs = append(cardIDs, card.ID) + } + + if len(cardIDs) == 0 { + return &dto.RecallStandaloneCardsResponse{ + TotalCount: len(cards), + SuccessCount: 0, + FailCount: len(failedItems), + FailedItems: failedItems, + }, nil + } + + isPlatform := operatorShopID == nil + var newShopID *uint + var newStatus int + if isPlatform { + newShopID = nil + newStatus = constants.IotCardStatusInStock + } else { + newShopID = operatorShopID + newStatus = constants.IotCardStatusDistributed + } + + err = s.db.Transaction(func(tx *gorm.DB) error { + txIotCardStore := postgres.NewIotCardStore(tx, nil) + txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil) + + if err := txIotCardStore.BatchUpdateShopIDAndStatus(ctx, cardIDs, newShopID, newStatus); err != nil { + return err + } + + allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall) + records := s.buildRecallRecords(cards, cardIDs, req.FromShopID, operatorShopID, operatorID, allocationNo, req.Remark) + return txRecordStore.BatchCreate(ctx, records) + }) + + if err != nil { + return nil, err + } + + return &dto.RecallStandaloneCardsResponse{ + TotalCount: len(cards), + SuccessCount: len(cardIDs), + FailCount: len(failedItems), + AllocationNo: s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall), + FailedItems: failedItems, + }, nil +} + +func (s *Service) validateDirectSubordinate(ctx context.Context, operatorShopID *uint, targetShopID uint) error { + if operatorShopID != nil && *operatorShopID == targetShopID { + return errors.ErrCannotAllocateToSelf + } + + targetShop, err := s.shopStore.GetByID(ctx, targetShopID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeShopNotFound) + } + return err + } + + if operatorShopID == nil { + if targetShop.ParentID != nil { + return errors.ErrNotDirectSubordinate + } + } else { + if targetShop.ParentID == nil || *targetShop.ParentID != *operatorShopID { + return errors.ErrNotDirectSubordinate + } + } + + return nil +} + +func (s *Service) getCardsForAllocation(ctx context.Context, req *dto.AllocateStandaloneCardsRequest, operatorShopID *uint) ([]*model.IotCard, error) { + switch req.SelectionType { + case dto.SelectionTypeList: + return s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs) + case dto.SelectionTypeRange: + return s.iotCardStore.GetStandaloneByICCIDRange(ctx, req.ICCIDStart, req.ICCIDEnd, operatorShopID) + case dto.SelectionTypeFilter: + filters := make(map[string]any) + if req.CarrierID != nil { + filters["carrier_id"] = *req.CarrierID + } + if req.BatchNo != "" { + filters["batch_no"] = req.BatchNo + } + if req.Status != nil { + filters["status"] = *req.Status + } + return s.iotCardStore.GetStandaloneByFilters(ctx, filters, operatorShopID) + default: + return nil, errors.New(errors.CodeInvalidParam, "无效的选卡方式") + } +} + +func (s *Service) getCardsForRecall(ctx context.Context, req *dto.RecallStandaloneCardsRequest) ([]*model.IotCard, error) { + fromShopID := req.FromShopID + switch req.SelectionType { + case dto.SelectionTypeList: + return s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs) + case dto.SelectionTypeRange: + return s.iotCardStore.GetStandaloneByICCIDRange(ctx, req.ICCIDStart, req.ICCIDEnd, &fromShopID) + case dto.SelectionTypeFilter: + filters := make(map[string]any) + if req.CarrierID != nil { + filters["carrier_id"] = *req.CarrierID + } + if req.BatchNo != "" { + filters["batch_no"] = req.BatchNo + } + return s.iotCardStore.GetStandaloneByFilters(ctx, filters, &fromShopID) + default: + return nil, errors.New(errors.CodeInvalidParam, "无效的选卡方式") + } +} + +func (s *Service) extractCardIDs(cards []*model.IotCard) []uint { + ids := make([]uint, len(cards)) + for i, card := range cards { + ids[i] = card.ID + } + return ids +} + +func (s *Service) buildAllocationRecords(cards []*model.IotCard, successCardIDs []uint, fromShopID *uint, toShopID uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord { + successIDSet := make(map[uint]bool) + for _, id := range successCardIDs { + successIDSet[id] = true + } + + var records []*model.AssetAllocationRecord + for _, card := range cards { + if !successIDSet[card.ID] { + continue + } + + record := &model.AssetAllocationRecord{ + AllocationNo: allocationNo, + AllocationType: constants.AssetAllocationTypeAllocate, + AssetType: constants.AssetTypeIotCard, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + ToOwnerType: constants.OwnerTypeShop, + ToOwnerID: toShopID, + OperatorID: operatorID, + Remark: remark, + } + + if fromShopID == nil { + record.FromOwnerType = constants.OwnerTypePlatform + record.FromOwnerID = nil + } else { + record.FromOwnerType = constants.OwnerTypeShop + record.FromOwnerID = fromShopID + } + + records = append(records, record) + } + + return records +} + +func (s *Service) buildRecallRecords(cards []*model.IotCard, successCardIDs []uint, fromShopID uint, toShopID *uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord { + successIDSet := make(map[uint]bool) + for _, id := range successCardIDs { + successIDSet[id] = true + } + + var records []*model.AssetAllocationRecord + for _, card := range cards { + if !successIDSet[card.ID] { + continue + } + + record := &model.AssetAllocationRecord{ + AllocationNo: allocationNo, + AllocationType: constants.AssetAllocationTypeRecall, + AssetType: constants.AssetTypeIotCard, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + FromOwnerType: constants.OwnerTypeShop, + FromOwnerID: &fromShopID, + OperatorID: operatorID, + Remark: remark, + } + + if toShopID == nil { + record.ToOwnerType = constants.OwnerTypePlatform + record.ToOwnerID = 0 + } else { + record.ToOwnerType = constants.OwnerTypeShop + record.ToOwnerID = *toShopID + } + + records = append(records, record) + } + + return records +} diff --git a/internal/store/postgres/asset_allocation_record_store.go b/internal/store/postgres/asset_allocation_record_store.go new file mode 100644 index 0000000..7be82a8 --- /dev/null +++ b/internal/store/postgres/asset_allocation_record_store.go @@ -0,0 +1,127 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +type AssetAllocationRecordStore struct { + db *gorm.DB + redis *redis.Client +} + +func NewAssetAllocationRecordStore(db *gorm.DB, redis *redis.Client) *AssetAllocationRecordStore { + return &AssetAllocationRecordStore{ + db: db, + redis: redis, + } +} + +func (s *AssetAllocationRecordStore) Create(ctx context.Context, record *model.AssetAllocationRecord) error { + return s.db.WithContext(ctx).Create(record).Error +} + +func (s *AssetAllocationRecordStore) BatchCreate(ctx context.Context, records []*model.AssetAllocationRecord) error { + if len(records) == 0 { + return nil + } + return s.db.WithContext(ctx).CreateInBatches(records, 100).Error +} + +func (s *AssetAllocationRecordStore) GetByID(ctx context.Context, id uint) (*model.AssetAllocationRecord, error) { + var record model.AssetAllocationRecord + if err := s.db.WithContext(ctx).First(&record, id).Error; err != nil { + return nil, err + } + return &record, nil +} + +func (s *AssetAllocationRecordStore) GetByAllocationNo(ctx context.Context, allocationNo string) (*model.AssetAllocationRecord, error) { + var record model.AssetAllocationRecord + if err := s.db.WithContext(ctx).Where("allocation_no = ?", allocationNo).First(&record).Error; err != nil { + return nil, err + } + return &record, nil +} + +func (s *AssetAllocationRecordStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.AssetAllocationRecord, int64, error) { + var records []*model.AssetAllocationRecord + var total int64 + + query := s.db.WithContext(ctx).Model(&model.AssetAllocationRecord{}) + + if allocationType, ok := filters["allocation_type"].(string); ok && allocationType != "" { + query = query.Where("allocation_type = ?", allocationType) + } + if assetType, ok := filters["asset_type"].(string); ok && assetType != "" { + query = query.Where("asset_type = ?", assetType) + } + if assetIdentifier, ok := filters["asset_identifier"].(string); ok && assetIdentifier != "" { + query = query.Where("asset_identifier LIKE ?", "%"+assetIdentifier+"%") + } + if allocationNo, ok := filters["allocation_no"].(string); ok && allocationNo != "" { + query = query.Where("allocation_no = ?", allocationNo) + } + if fromShopID, ok := filters["from_shop_id"].(uint); ok && fromShopID > 0 { + query = query.Where("from_owner_type = ? AND from_owner_id = ?", constants.OwnerTypeShop, fromShopID) + } + if toShopID, ok := filters["to_shop_id"].(uint); ok && toShopID > 0 { + query = query.Where("to_owner_type = ? AND to_owner_id = ?", constants.OwnerTypeShop, toShopID) + } + if operatorID, ok := filters["operator_id"].(uint); ok && operatorID > 0 { + query = query.Where("operator_id = ?", operatorID) + } + if createdAtStart, ok := filters["created_at_start"].(time.Time); ok { + query = query.Where("created_at >= ?", createdAtStart) + } + if createdAtEnd, ok := filters["created_at_end"].(time.Time); ok { + query = query.Where("created_at <= ?", createdAtEnd) + } + if relatedShopIDs, ok := filters["related_shop_ids"].([]uint); ok && len(relatedShopIDs) > 0 { + query = query.Where( + "(from_owner_type = ? AND from_owner_id IN ?) OR (to_owner_type = ? AND to_owner_id IN ?)", + constants.OwnerTypeShop, relatedShopIDs, + constants.OwnerTypeShop, relatedShopIDs, + ) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + if opts == nil { + opts = &store.QueryOptions{ + Page: 1, + PageSize: constants.DefaultPageSize, + } + } + offset := (opts.Page - 1) * opts.PageSize + query = query.Offset(offset).Limit(opts.PageSize) + + if opts.OrderBy != "" { + query = query.Order(opts.OrderBy) + } else { + query = query.Order("created_at DESC") + } + + if err := query.Find(&records).Error; err != nil { + return nil, 0, err + } + + return records, total, nil +} + +func (s *AssetAllocationRecordStore) GenerateAllocationNo(ctx context.Context, allocationType string) string { + prefix := "AL" + if allocationType == constants.AssetAllocationTypeRecall { + prefix = "RC" + } + return fmt.Sprintf("%s%s%d", prefix, time.Now().Format("20060102150405"), time.Now().UnixNano()%10000) +} diff --git a/internal/store/postgres/asset_allocation_record_store_test.go b/internal/store/postgres/asset_allocation_record_store_test.go new file mode 100644 index 0000000..b5b4f04 --- /dev/null +++ b/internal/store/postgres/asset_allocation_record_store_test.go @@ -0,0 +1,232 @@ +package postgres + +import ( + "context" + "testing" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/tests/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAssetAllocationRecordStore_Create(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + s := NewAssetAllocationRecordStore(tx, rdb) + ctx := context.Background() + + record := &model.AssetAllocationRecord{ + AllocationNo: "AL20260124100001", + AllocationType: constants.AssetAllocationTypeAllocate, + AssetType: constants.AssetTypeIotCard, + AssetID: 1, + AssetIdentifier: "89860012345678901234", + FromOwnerType: constants.OwnerTypePlatform, + ToOwnerType: constants.OwnerTypeShop, + ToOwnerID: 10, + OperatorID: 1, + } + + err := s.Create(ctx, record) + require.NoError(t, err) + assert.NotZero(t, record.ID) +} + +func TestAssetAllocationRecordStore_BatchCreate(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + s := NewAssetAllocationRecordStore(tx, rdb) + ctx := context.Background() + + records := []*model.AssetAllocationRecord{ + { + AllocationNo: "AL20260124100010", + AllocationType: constants.AssetAllocationTypeAllocate, + AssetType: constants.AssetTypeIotCard, + AssetID: 1, + AssetIdentifier: "89860012345678901001", + FromOwnerType: constants.OwnerTypePlatform, + ToOwnerType: constants.OwnerTypeShop, + ToOwnerID: 10, + OperatorID: 1, + }, + { + AllocationNo: "AL20260124100011", + AllocationType: constants.AssetAllocationTypeAllocate, + AssetType: constants.AssetTypeIotCard, + AssetID: 2, + AssetIdentifier: "89860012345678901002", + FromOwnerType: constants.OwnerTypePlatform, + ToOwnerType: constants.OwnerTypeShop, + ToOwnerID: 10, + OperatorID: 1, + }, + } + + err := s.BatchCreate(ctx, records) + require.NoError(t, err) + + for _, record := range records { + assert.NotZero(t, record.ID) + } + + t.Run("空列表不报错", func(t *testing.T) { + err := s.BatchCreate(ctx, []*model.AssetAllocationRecord{}) + require.NoError(t, err) + }) +} + +func TestAssetAllocationRecordStore_GetByID(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + s := NewAssetAllocationRecordStore(tx, rdb) + ctx := context.Background() + + record := &model.AssetAllocationRecord{ + AllocationNo: "AL20260124100003", + AllocationType: constants.AssetAllocationTypeAllocate, + AssetType: constants.AssetTypeIotCard, + AssetID: 1, + AssetIdentifier: "89860012345678903001", + FromOwnerType: constants.OwnerTypePlatform, + ToOwnerType: constants.OwnerTypeShop, + ToOwnerID: 10, + OperatorID: 1, + Remark: "测试备注", + } + require.NoError(t, s.Create(ctx, record)) + + result, err := s.GetByID(ctx, record.ID) + require.NoError(t, err) + assert.Equal(t, record.AllocationNo, result.AllocationNo) + assert.Equal(t, record.AssetIdentifier, result.AssetIdentifier) + assert.Equal(t, "测试备注", result.Remark) +} + +func TestAssetAllocationRecordStore_List(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + s := NewAssetAllocationRecordStore(tx, rdb) + ctx := context.Background() + + shopID := uint(100) + records := []*model.AssetAllocationRecord{ + { + AllocationNo: "AL20260124100004", + AllocationType: constants.AssetAllocationTypeAllocate, + AssetType: constants.AssetTypeIotCard, + AssetID: 1, + AssetIdentifier: "89860012345678904001", + FromOwnerType: constants.OwnerTypePlatform, + ToOwnerType: constants.OwnerTypeShop, + ToOwnerID: shopID, + OperatorID: 1, + }, + { + AllocationNo: "AL20260124100005", + AllocationType: constants.AssetAllocationTypeAllocate, + AssetType: constants.AssetTypeIotCard, + AssetID: 2, + AssetIdentifier: "89860012345678904002", + FromOwnerType: constants.OwnerTypeShop, + FromOwnerID: &shopID, + ToOwnerType: constants.OwnerTypeShop, + ToOwnerID: 200, + OperatorID: 2, + }, + { + AllocationNo: "RC20260124100001", + AllocationType: constants.AssetAllocationTypeRecall, + AssetType: constants.AssetTypeIotCard, + AssetID: 3, + AssetIdentifier: "89860012345678904003", + FromOwnerType: constants.OwnerTypeShop, + FromOwnerID: &shopID, + ToOwnerType: constants.OwnerTypePlatform, + ToOwnerID: 0, + OperatorID: 1, + }, + } + require.NoError(t, s.BatchCreate(ctx, records)) + + t.Run("查询所有记录", func(t *testing.T) { + result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, nil) + require.NoError(t, err) + assert.Equal(t, int64(3), total) + assert.Len(t, result, 3) + }) + + t.Run("按分配类型过滤", func(t *testing.T) { + filters := map[string]any{"allocation_type": constants.AssetAllocationTypeAllocate} + result, total, err := s.List(ctx, nil, filters) + require.NoError(t, err) + assert.Equal(t, int64(2), total) + for _, r := range result { + assert.Equal(t, constants.AssetAllocationTypeAllocate, r.AllocationType) + } + }) + + t.Run("按分配单号过滤", func(t *testing.T) { + filters := map[string]any{"allocation_no": "AL20260124100004"} + result, total, err := s.List(ctx, nil, filters) + require.NoError(t, err) + assert.Equal(t, int64(1), total) + assert.Equal(t, "AL20260124100004", result[0].AllocationNo) + }) + + t.Run("按资产标识模糊查询", func(t *testing.T) { + filters := map[string]any{"asset_identifier": "904002"} + result, total, err := s.List(ctx, nil, filters) + require.NoError(t, err) + assert.Equal(t, int64(1), total) + assert.Contains(t, result[0].AssetIdentifier, "904002") + }) + + t.Run("按目标店铺过滤", func(t *testing.T) { + filters := map[string]any{"to_shop_id": shopID} + result, total, err := s.List(ctx, nil, filters) + require.NoError(t, err) + assert.Equal(t, int64(1), total) + assert.Equal(t, shopID, result[0].ToOwnerID) + }) + + t.Run("按操作人过滤", func(t *testing.T) { + filters := map[string]any{"operator_id": uint(2)} + result, total, err := s.List(ctx, nil, filters) + require.NoError(t, err) + assert.Equal(t, int64(1), total) + assert.Equal(t, uint(2), result[0].OperatorID) + }) +} + +func TestAssetAllocationRecordStore_GenerateAllocationNo(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + s := NewAssetAllocationRecordStore(tx, rdb) + ctx := context.Background() + + t.Run("分配单号前缀为AL", func(t *testing.T) { + no := s.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate) + assert.True(t, len(no) > 0) + assert.Equal(t, "AL", no[:2]) + }) + + t.Run("回收单号前缀为RC", func(t *testing.T) { + no := s.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall) + assert.True(t, len(no) > 0) + assert.Equal(t, "RC", no[:2]) + }) +} diff --git a/internal/store/postgres/iot_card_store.go b/internal/store/postgres/iot_card_store.go index 6a53748..5e49962 100644 --- a/internal/store/postgres/iot_card_store.go +++ b/internal/store/postgres/iot_card_store.go @@ -172,11 +172,50 @@ func (s *IotCardStore) ListStandalone(ctx context.Context, opts *store.QueryOpti return cards, total, nil } -func (s *IotCardStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.IotCard, int64, error) { +func (s *IotCardStore) GetByICCIDs(ctx context.Context, iccids []string) ([]*model.IotCard, error) { + if len(iccids) == 0 { + return nil, nil + } var cards []*model.IotCard - var total int64 + if err := s.db.WithContext(ctx).Where("iccid IN ?", iccids).Find(&cards).Error; err != nil { + return nil, err + } + return cards, nil +} - query := s.db.WithContext(ctx).Model(&model.IotCard{}) +func (s *IotCardStore) GetStandaloneByICCIDRange(ctx context.Context, iccidStart, iccidEnd string, shopID *uint) ([]*model.IotCard, error) { + query := s.db.WithContext(ctx).Model(&model.IotCard{}). + Where("id NOT IN (?)", + s.db.Model(&model.DeviceSimBinding{}). + Select("iot_card_id"). + Where("bind_status = ?", 1)). + Where("iccid >= ? AND iccid <= ?", iccidStart, iccidEnd) + + if shopID == nil { + query = query.Where("shop_id IS NULL") + } else { + query = query.Where("shop_id = ?", *shopID) + } + + var cards []*model.IotCard + if err := query.Find(&cards).Error; err != nil { + return nil, err + } + return cards, nil +} + +func (s *IotCardStore) GetStandaloneByFilters(ctx context.Context, filters map[string]any, shopID *uint) ([]*model.IotCard, error) { + query := s.db.WithContext(ctx).Model(&model.IotCard{}). + Where("id NOT IN (?)", + s.db.Model(&model.DeviceSimBinding{}). + Select("iot_card_id"). + Where("bind_status = ?", 1)) + + if shopID == nil { + query = query.Where("shop_id IS NULL") + } else { + query = query.Where("shop_id = ?", *shopID) + } if status, ok := filters["status"].(int); ok && status > 0 { query = query.Where("status = ?", status) @@ -184,35 +223,38 @@ func (s *IotCardStore) List(ctx context.Context, opts *store.QueryOptions, filte if carrierID, ok := filters["carrier_id"].(uint); ok && carrierID > 0 { query = query.Where("carrier_id = ?", carrierID) } - if shopID, ok := filters["shop_id"].(uint); ok && shopID > 0 { - query = query.Where("shop_id = ?", shopID) - } - if iccid, ok := filters["iccid"].(string); ok && iccid != "" { - query = query.Where("iccid LIKE ?", "%"+iccid+"%") - } - - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - - if opts == nil { - opts = &store.QueryOptions{ - Page: 1, - PageSize: constants.DefaultPageSize, - } - } - offset := (opts.Page - 1) * opts.PageSize - query = query.Offset(offset).Limit(opts.PageSize) - - if opts.OrderBy != "" { - query = query.Order(opts.OrderBy) - } else { - query = query.Order("created_at DESC") + if batchNo, ok := filters["batch_no"].(string); ok && batchNo != "" { + query = query.Where("batch_no = ?", batchNo) } + var cards []*model.IotCard if err := query.Find(&cards).Error; err != nil { - return nil, 0, err + return nil, err } - - return cards, total, nil + return cards, nil +} + +func (s *IotCardStore) BatchUpdateShopIDAndStatus(ctx context.Context, cardIDs []uint, shopID *uint, status int) error { + if len(cardIDs) == 0 { + return nil + } + updates := map[string]any{ + "shop_id": shopID, + "status": status, + } + return s.db.WithContext(ctx).Model(&model.IotCard{}). + Where("id IN ?", cardIDs). + Updates(updates).Error +} + +func (s *IotCardStore) GetBoundCardIDs(ctx context.Context, cardIDs []uint) ([]uint, error) { + if len(cardIDs) == 0 { + return nil, nil + } + var boundCardIDs []uint + err := s.db.WithContext(ctx).Model(&model.DeviceSimBinding{}). + Select("iot_card_id"). + Where("iot_card_id IN ? AND bind_status = ?", cardIDs, 1). + Pluck("iot_card_id", &boundCardIDs).Error + return boundCardIDs, err } diff --git a/internal/store/postgres/iot_card_store_test.go b/internal/store/postgres/iot_card_store_test.go index e5ae048..493816f 100644 --- a/internal/store/postgres/iot_card_store_test.go +++ b/internal/store/postgres/iot_card_store_test.go @@ -240,3 +240,174 @@ func TestIotCardStore_ListStandalone_Filters(t *testing.T) { assert.Equal(t, int64(2), total) }) } + +func TestIotCardStore_GetByICCIDs(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + s := NewIotCardStore(tx, rdb) + ctx := context.Background() + + cards := []*model.IotCard{ + {ICCID: "89860012345678905001", CardType: "data_card", CarrierID: 1, Status: 1}, + {ICCID: "89860012345678905002", CardType: "data_card", CarrierID: 1, Status: 1}, + {ICCID: "89860012345678905003", CardType: "data_card", CarrierID: 1, Status: 1}, + } + require.NoError(t, s.CreateBatch(ctx, cards)) + + t.Run("查询存在的ICCID", func(t *testing.T) { + result, err := s.GetByICCIDs(ctx, []string{"89860012345678905001", "89860012345678905002"}) + require.NoError(t, err) + assert.Len(t, result, 2) + }) + + t.Run("查询不存在的ICCID", func(t *testing.T) { + result, err := s.GetByICCIDs(ctx, []string{"89860012345678909999"}) + require.NoError(t, err) + assert.Len(t, result, 0) + }) + + t.Run("空列表返回nil", func(t *testing.T) { + result, err := s.GetByICCIDs(ctx, []string{}) + require.NoError(t, err) + assert.Nil(t, result) + }) +} + +func TestIotCardStore_GetStandaloneByICCIDRange(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + s := NewIotCardStore(tx, rdb) + ctx := context.Background() + + shopID := uint(100) + cards := []*model.IotCard{ + {ICCID: "89860012345678906001", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil}, + {ICCID: "89860012345678906002", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil}, + {ICCID: "89860012345678906003", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: &shopID}, + {ICCID: "89860012345678906004", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: &shopID}, + } + require.NoError(t, s.CreateBatch(ctx, cards)) + + t.Run("平台查询未分配的卡", func(t *testing.T) { + result, err := s.GetStandaloneByICCIDRange(ctx, "89860012345678906001", "89860012345678906004", nil) + require.NoError(t, err) + assert.Len(t, result, 2) + for _, card := range result { + assert.Nil(t, card.ShopID) + } + }) + + t.Run("店铺查询自己的卡", func(t *testing.T) { + result, err := s.GetStandaloneByICCIDRange(ctx, "89860012345678906001", "89860012345678906004", &shopID) + require.NoError(t, err) + assert.Len(t, result, 2) + for _, card := range result { + assert.Equal(t, shopID, *card.ShopID) + } + }) +} + +func TestIotCardStore_GetStandaloneByFilters(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + s := NewIotCardStore(tx, rdb) + ctx := context.Background() + + shopID := uint(100) + cards := []*model.IotCard{ + {ICCID: "89860012345678907001", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil, BatchNo: "BATCH001"}, + {ICCID: "89860012345678907002", CardType: "data_card", CarrierID: 2, Status: 1, ShopID: nil, BatchNo: "BATCH002"}, + {ICCID: "89860012345678907003", CardType: "data_card", CarrierID: 1, Status: 2, ShopID: &shopID, BatchNo: "BATCH001"}, + } + require.NoError(t, s.CreateBatch(ctx, cards)) + + t.Run("按运营商过滤", func(t *testing.T) { + filters := map[string]any{"carrier_id": uint(1)} + result, err := s.GetStandaloneByFilters(ctx, filters, nil) + require.NoError(t, err) + assert.Len(t, result, 1) + assert.Equal(t, uint(1), result[0].CarrierID) + }) + + t.Run("按批次号过滤", func(t *testing.T) { + filters := map[string]any{"batch_no": "BATCH001"} + result, err := s.GetStandaloneByFilters(ctx, filters, &shopID) + require.NoError(t, err) + assert.Len(t, result, 1) + assert.Equal(t, "BATCH001", result[0].BatchNo) + }) +} + +func TestIotCardStore_BatchUpdateShopIDAndStatus(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + s := NewIotCardStore(tx, rdb) + ctx := context.Background() + + cards := []*model.IotCard{ + {ICCID: "89860012345678908001", CardType: "data_card", CarrierID: 1, Status: 1}, + {ICCID: "89860012345678908002", CardType: "data_card", CarrierID: 1, Status: 1}, + } + require.NoError(t, s.CreateBatch(ctx, cards)) + + newShopID := uint(200) + cardIDs := []uint{cards[0].ID, cards[1].ID} + + err := s.BatchUpdateShopIDAndStatus(ctx, cardIDs, &newShopID, 2) + require.NoError(t, err) + + var updatedCards []*model.IotCard + require.NoError(t, tx.Where("id IN ?", cardIDs).Find(&updatedCards).Error) + for _, card := range updatedCards { + assert.Equal(t, newShopID, *card.ShopID) + assert.Equal(t, 2, card.Status) + } + + t.Run("空列表不报错", func(t *testing.T) { + err := s.BatchUpdateShopIDAndStatus(ctx, []uint{}, nil, 1) + require.NoError(t, err) + }) +} + +func TestIotCardStore_GetBoundCardIDs(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + s := NewIotCardStore(tx, rdb) + ctx := context.Background() + + cards := []*model.IotCard{ + {ICCID: "89860012345678909001", CardType: "data_card", CarrierID: 1, Status: 1}, + {ICCID: "89860012345678909002", CardType: "data_card", CarrierID: 1, Status: 1}, + {ICCID: "89860012345678909003", CardType: "data_card", CarrierID: 1, Status: 1}, + } + require.NoError(t, s.CreateBatch(ctx, cards)) + + binding := &model.DeviceSimBinding{ + DeviceID: 1, + IotCardID: cards[0].ID, + BindStatus: 1, + } + require.NoError(t, tx.Create(binding).Error) + + cardIDs := []uint{cards[0].ID, cards[1].ID, cards[2].ID} + boundIDs, err := s.GetBoundCardIDs(ctx, cardIDs) + require.NoError(t, err) + assert.Len(t, boundIDs, 1) + assert.Contains(t, boundIDs, cards[0].ID) + + t.Run("空列表返回nil", func(t *testing.T) { + result, err := s.GetBoundCardIDs(ctx, []uint{}) + require.NoError(t, err) + assert.Nil(t, result) + }) +} diff --git a/migrations/000014_create_asset_allocation_record_table.down.sql b/migrations/000014_create_asset_allocation_record_table.down.sql new file mode 100644 index 0000000..28e1e4f --- /dev/null +++ b/migrations/000014_create_asset_allocation_record_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS tb_asset_allocation_record; diff --git a/migrations/000014_create_asset_allocation_record_table.up.sql b/migrations/000014_create_asset_allocation_record_table.up.sql new file mode 100644 index 0000000..50aa10f --- /dev/null +++ b/migrations/000014_create_asset_allocation_record_table.up.sql @@ -0,0 +1,47 @@ +-- 创建资产分配记录表 +-- 记录卡/设备在平台和代理商之间的流转历史 + +CREATE TABLE IF NOT EXISTS tb_asset_allocation_record ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMPTZ, + creator BIGINT, + updater BIGINT, + allocation_no VARCHAR(50) NOT NULL, + allocation_type VARCHAR(20) NOT NULL, + asset_type VARCHAR(20) NOT NULL, + asset_id BIGINT NOT NULL, + asset_identifier VARCHAR(50) NOT NULL, + from_owner_type VARCHAR(20), + from_owner_id BIGINT, + to_owner_type VARCHAR(20) NOT NULL, + to_owner_id BIGINT NOT NULL, + related_device_id BIGINT, + related_card_ids JSONB, + operator_id BIGINT NOT NULL, + remark TEXT +); + +-- 创建索引(非唯一,同批次多条记录共用相同单号) +CREATE INDEX IF NOT EXISTS idx_asset_allocation_record_allocation_no ON tb_asset_allocation_record(allocation_no); +CREATE INDEX IF NOT EXISTS idx_asset_allocation_record_allocation_type ON tb_asset_allocation_record(allocation_type); +CREATE INDEX IF NOT EXISTS idx_asset_allocation_record_asset_type ON tb_asset_allocation_record(asset_type); +CREATE INDEX IF NOT EXISTS idx_asset_allocation_record_asset_id ON tb_asset_allocation_record(asset_id); +CREATE INDEX IF NOT EXISTS idx_asset_allocation_record_deleted_at ON tb_asset_allocation_record(deleted_at); + +-- 添加表注释 +COMMENT ON TABLE tb_asset_allocation_record IS '资产分配记录表'; +COMMENT ON COLUMN tb_asset_allocation_record.allocation_no IS '分配单号(同批次相同)'; +COMMENT ON COLUMN tb_asset_allocation_record.allocation_type IS '分配类型 allocate=分配 recall=回收'; +COMMENT ON COLUMN tb_asset_allocation_record.asset_type IS '资产类型 iot_card=物联网卡 device=设备'; +COMMENT ON COLUMN tb_asset_allocation_record.asset_id IS '资产ID'; +COMMENT ON COLUMN tb_asset_allocation_record.asset_identifier IS '资产标识符(ICCID或设备号)'; +COMMENT ON COLUMN tb_asset_allocation_record.from_owner_type IS '来源所有者类型 platform=平台 shop=店铺'; +COMMENT ON COLUMN tb_asset_allocation_record.from_owner_id IS '来源所有者ID'; +COMMENT ON COLUMN tb_asset_allocation_record.to_owner_type IS '目标所有者类型 platform=平台 shop=店铺'; +COMMENT ON COLUMN tb_asset_allocation_record.to_owner_id IS '目标所有者ID'; +COMMENT ON COLUMN tb_asset_allocation_record.related_device_id IS '关联设备ID(设备分配时使用)'; +COMMENT ON COLUMN tb_asset_allocation_record.related_card_ids IS '关联卡ID列表(设备分配时使用)'; +COMMENT ON COLUMN tb_asset_allocation_record.operator_id IS '操作人ID'; +COMMENT ON COLUMN tb_asset_allocation_record.remark IS '备注'; diff --git a/openspec/changes/archive/2026-01-24-add-standalone-card-allocation/proposal.md b/openspec/changes/archive/2026-01-24-add-standalone-card-allocation/proposal.md new file mode 100644 index 0000000..3c94322 --- /dev/null +++ b/openspec/changes/archive/2026-01-24-add-standalone-card-allocation/proposal.md @@ -0,0 +1,82 @@ +# Change: 单卡资产分配功能 + +## Why + +平台和代理商需要将单卡(未绑定设备的 IoT 卡)分销给下级代理,实现资产的层级流转。当前系统只有企业卡授权功能(授权企业可见特定卡),缺少代理商之间的卡所有权转移功能。 + +业务场景: +- 平台批量导入卡后,分销给一级代理 +- 一级代理继续分销给二级代理 +- 上级可以回收已分销的卡 +- 查看卡的分配/回收历史记录 + +## What Changes + +### 新增功能 + +**单卡分配 API** +- `POST /api/admin/iot-cards/standalone/allocate` - 批量分配单卡给直属下级店铺 +- `POST /api/admin/iot-cards/standalone/recall` - 批量回收已分配的单卡 + +**分配记录查询 API** +- `GET /api/admin/asset-allocation-records` - 分配记录列表(支持分页和筛选) +- `GET /api/admin/asset-allocation-records/:id` - 分配记录详情 + +**分配方式支持** +- ICCID 列表选择 +- ICCID 号段范围(起始~结束) +- 筛选条件批量(运营商、批次号等) + +### 业务规则 + +**分配规则** +- 只能分配给直属下级店铺,不可跨级 +- 平台只能分配在库(status=1)的卡 +- 代理可以分配已分销(status=2)的卡(继续往下分销) +- 分配后状态变更:在库(1)→已分销(2),已分销(2)保持不变 +- 分配后 shop_id 变更为目标店铺 ID + +**回收规则** +- 只有上级可以回收,代理不能主动退回 +- 平台回收:shop_id 变为 NULL +- 店铺回收:shop_id 变为执行回收的店铺 ID +- 只能回收直属下级的卡,不可跨级回收 + +**可见性** +- 分配后上级仍能看到和管理(通过数据权限机制实现) + +**注意** +- 本次只做单卡分配,设备卡分配暂不实现 +- 分配不涉及费用,纯资产所有权转移 + +## Capabilities + +### New Capabilities + +- `asset-allocation-record`: 资产分配记录管理,包含分配记录的查询功能 + +### Modified Capabilities + +- `iot-card`: 新增单卡分配和回收功能 + +## Impact + +### API 影响 +- 新增:`POST /api/admin/iot-cards/standalone/allocate` +- 新增:`POST /api/admin/iot-cards/standalone/recall` +- 新增:`GET /api/admin/asset-allocation-records` +- 新增:`GET /api/admin/asset-allocation-records/:id` + +### 代码影响 +- `internal/handler/admin/iot_card.go`:新增 AllocateCards、RecallCards 方法 +- `internal/handler/admin/asset_allocation_record.go`:新增(分配记录 Handler) +- `internal/service/iot_card/service.go`:新增分配、回收业务逻辑 +- `internal/service/asset_allocation_record/service.go`:新增(分配记录 Service) +- `internal/store/postgres/iot_card_store.go`:新增批量更新 shop_id 方法 +- `internal/store/postgres/asset_allocation_record_store.go`:新增(分配记录 Store) +- `internal/model/dto/iot_card_dto.go`:新增分配、回收相关 DTO +- `internal/model/dto/asset_allocation_record_dto.go`:新增(分配记录 DTO) +- `internal/routes/asset_allocation_record.go`:新增(分配记录路由) + +### 数据库影响 +- 无表结构变更(使用现有 `tb_iot_card` 和 `tb_asset_allocation_record` 表) diff --git a/openspec/changes/archive/2026-01-24-add-standalone-card-allocation/specs/asset-allocation-record/spec.md b/openspec/changes/archive/2026-01-24-add-standalone-card-allocation/specs/asset-allocation-record/spec.md new file mode 100644 index 0000000..0088726 --- /dev/null +++ b/openspec/changes/archive/2026-01-24-add-standalone-card-allocation/specs/asset-allocation-record/spec.md @@ -0,0 +1,101 @@ +## ADDED Requirements + +### Requirement: 资产分配记录查询 + +系统 SHALL 提供资产分配记录的查询功能,支持查看卡和设备在平台与代理商之间的流转历史。 + +**记录类型**: +- `allocate`: 分配记录(上级分配给下级) +- `recall`: 回收记录(上级从下级回收) + +**资产类型**: +- `iot_card`: 物联网卡(单卡) +- `device`: 设备(未来扩展) + +**查询条件**: +- `allocation_type`(可选): 分配类型,枚举值 "allocate" | "recall" +- `asset_type`(可选): 资产类型,枚举值 "iot_card" | "device" +- `asset_identifier`(可选): 资产标识符(ICCID 或设备号),模糊匹配 +- `allocation_no`(可选): 分配单号,精确匹配 +- `from_shop_id`(可选): 来源店铺 ID +- `to_shop_id`(可选): 目标店铺 ID +- `operator_id`(可选): 操作人 ID +- `created_at_start`(可选): 创建时间起始 +- `created_at_end`(可选): 创建时间结束 + +**分页**: +- 默认每页 20 条,最大每页 100 条 +- 返回总记录数和总页数 + +**数据权限**: +- 平台用户可查看所有记录 +- 代理用户只能查看与自己店铺相关的记录(作为来源或目标) + +**API 端点**: `GET /api/admin/asset-allocation-records` + +**响应字段**: +- `id`: 记录 ID +- `allocation_no`: 分配单号 +- `allocation_type`: 分配类型 +- `allocation_type_name`: 分配类型名称(分配/回收) +- `asset_type`: 资产类型 +- `asset_type_name`: 资产类型名称(物联网卡/设备) +- `asset_id`: 资产 ID +- `asset_identifier`: 资产标识符 +- `from_owner_type`: 来源所有者类型 +- `from_owner_id`: 来源所有者 ID +- `from_owner_name`: 来源所有者名称 +- `to_owner_type`: 目标所有者类型 +- `to_owner_id`: 目标所有者 ID +- `to_owner_name`: 目标所有者名称 +- `operator_id`: 操作人 ID +- `operator_name`: 操作人名称 +- `remark`: 备注 +- `created_at`: 创建时间 + +#### Scenario: 查询所有分配记录 + +- **WHEN** 平台管理员查询分配记录列表,不带任何筛选条件 +- **THEN** 系统返回所有分配和回收记录,按创建时间倒序排列 + +#### Scenario: 按资产类型筛选记录 + +- **WHEN** 管理员查询资产类型为 "iot_card" 的记录 +- **THEN** 系统只返回物联网卡的分配/回收记录,不包含设备记录 + +#### Scenario: 按分配类型筛选记录 + +- **WHEN** 管理员查询分配类型为 "allocate" 的记录 +- **THEN** 系统只返回分配记录,不包含回收记录 + +#### Scenario: 按 ICCID 模糊查询 + +- **WHEN** 管理员输入 asset_identifier = "8986001" +- **THEN** 系统返回 ICCID 包含 "8986001" 的所有分配记录 + +#### Scenario: 代理查询自己相关的记录 + +- **WHEN** 代理用户(店铺 ID=10)查询分配记录 +- **THEN** 系统只返回 from_owner_id=10 或 to_owner_id=10 的记录 + +--- + +### Requirement: 资产分配记录详情 + +系统 SHALL 提供资产分配记录详情查询功能。 + +**API 端点**: `GET /api/admin/asset-allocation-records/:id` + +**响应**: +- 包含记录的所有字段 +- 关联卡 ID 列表(如果是设备分配,包含设备下的所有卡 ID) + +#### Scenario: 查询分配记录详情 + +- **WHEN** 管理员查询分配记录详情(ID=1) +- **THEN** 系统返回该记录的完整信息,包括来源/目标所有者名称、操作人名称等 + +#### Scenario: 查询不存在的记录 + +- **WHEN** 管理员查询不存在的分配记录(ID=999) +- **THEN** 系统返回 404 错误,提示"分配记录不存在" diff --git a/openspec/changes/archive/2026-01-24-add-standalone-card-allocation/specs/iot-card/spec.md b/openspec/changes/archive/2026-01-24-add-standalone-card-allocation/specs/iot-card/spec.md new file mode 100644 index 0000000..b7f313f --- /dev/null +++ b/openspec/changes/archive/2026-01-24-add-standalone-card-allocation/specs/iot-card/spec.md @@ -0,0 +1,129 @@ +## ADDED Requirements + +### Requirement: 单卡分配功能 + +系统 SHALL 支持将单卡(未绑定设备的 IoT 卡)分配给直属下级店铺,实现资产所有权的层级流转。 + +**分配规则**: +- 只能分配给直属下级店铺,不可跨级分配 +- 平台(shop_id=NULL)只能分配状态为在库(status=1)的卡 +- 代理店铺可以分配状态为已分销(status=2)的卡(继续往下分销) +- 分配后状态变更:在库(1)→已分销(2),已分销(2)保持不变 +- 分配后 shop_id 变更为目标店铺 ID +- 分配不涉及费用,纯资产所有权转移 +- 分配后上级仍能看到和管理(通过数据权限机制) + +**选卡方式**(三选一): +- ICCID 列表:指定具体的 ICCID 列表 +- 号段范围:指定起始 ICCID 和结束 ICCID +- 筛选条件:按运营商、批次号、状态等条件批量选择 + +**API 端点**: `POST /api/admin/iot-cards/standalone/allocate` + +**请求参数**: +- `to_shop_id`(必填): 目标店铺 ID +- `selection_type`(必填): 选卡方式,枚举值 "list" | "range" | "filter" +- `iccids`(selection_type=list 时必填): ICCID 列表 +- `iccid_start`(selection_type=range 时必填): 起始 ICCID +- `iccid_end`(selection_type=range 时必填): 结束 ICCID +- `carrier_id`(selection_type=filter 时可选): 运营商 ID +- `batch_no`(selection_type=filter 时可选): 批次号 +- `status`(selection_type=filter 时可选): 卡状态 +- `remark`(可选): 备注 + +**响应**: +- `total_count`: 待分配总数 +- `success_count`: 成功数 +- `fail_count`: 失败数 +- `failed_items`: 失败项列表(包含 ICCID 和失败原因) + +#### Scenario: 平台通过 ICCID 列表分配单卡给一级代理 + +- **WHEN** 平台管理员选择 3 张在库单卡(ICCID 列表),分配给一级代理店铺(ID=10) +- **THEN** 系统将这 3 张卡的 shop_id 更新为 10,status 从 1 变为 2,创建分配记录,返回成功数 3 + +#### Scenario: 平台通过号段范围批量分配单卡 + +- **WHEN** 平台管理员指定 ICCID 范围 "8986001000000000000" 至 "8986001000000000099",分配给一级代理店铺(ID=10) +- **THEN** 系统查询该范围内的所有在库单卡,批量更新 shop_id 和 status,创建分配记录 + +#### Scenario: 代理通过筛选条件分配单卡给下级 + +- **WHEN** 一级代理(店铺 ID=10)按条件筛选(运营商=电信,批次号=BATCH-001)自己的已分销卡,分配给二级代理店铺(ID=20) +- **THEN** 系统查询符合条件的卡,校验店铺 20 是店铺 10 的直属下级,批量更新 shop_id 为 20,status 保持 2 + +#### Scenario: 拒绝跨级分配 + +- **WHEN** 平台尝试将卡直接分配给二级代理店铺(非直属下级) +- **THEN** 系统拒绝分配,返回错误"只能分配给直属下级店铺" + +#### Scenario: 拒绝平台分配已分销的卡 + +- **WHEN** 平台尝试分配状态为已分销(status=2)的卡 +- **THEN** 系统拒绝分配,返回错误"在库状态的卡才能分配,请先回收" + +#### Scenario: 拒绝分配已绑定设备的卡 + +- **WHEN** 用户尝试分配已绑定设备的卡(在 device_sim_bindings 中 bind_status=1) +- **THEN** 系统拒绝分配,返回错误"已绑定设备的卡不能单独分配" + +--- + +### Requirement: 单卡回收功能 + +系统 SHALL 支持上级回收已分配给直属下级的单卡,将卡的所有权收回。 + +**回收规则**: +- 只有上级可以回收,代理不能主动退回给上级 +- 只能回收直属下级的卡,不可跨级回收 +- 平台回收:shop_id 变为 NULL,status 变为 1(在库) +- 店铺回收:shop_id 变为执行回收的店铺 ID,status 保持 2(已分销) +- 只能回收单卡(未绑定设备的卡) + +**选卡方式**(与分配相同,三选一): +- ICCID 列表 +- 号段范围 +- 筛选条件 + +**API 端点**: `POST /api/admin/iot-cards/standalone/recall` + +**请求参数**: +- `from_shop_id`(必填): 来源店铺 ID(被回收方) +- `selection_type`(必填): 选卡方式,枚举值 "list" | "range" | "filter" +- `iccids`(selection_type=list 时必填): ICCID 列表 +- `iccid_start`(selection_type=range 时必填): 起始 ICCID +- `iccid_end`(selection_type=range 时必填): 结束 ICCID +- `carrier_id`(selection_type=filter 时可选): 运营商 ID +- `batch_no`(selection_type=filter 时可选): 批次号 +- `remark`(可选): 备注 + +**响应**: +- `total_count`: 待回收总数 +- `success_count`: 成功数 +- `fail_count`: 失败数 +- `failed_items`: 失败项列表 + +#### Scenario: 平台回收一级代理的单卡 + +- **WHEN** 平台管理员选择一级代理店铺(ID=10)的 5 张单卡进行回收 +- **THEN** 系统将这 5 张卡的 shop_id 更新为 NULL,status 从 2 变为 1,创建回收记录 + +#### Scenario: 一级代理回收二级代理的单卡 + +- **WHEN** 一级代理(店铺 ID=10)选择二级代理店铺(ID=20)的 3 张单卡进行回收 +- **THEN** 系统将这 3 张卡的 shop_id 更新为 10,status 保持 2,创建回收记录 + +#### Scenario: 拒绝回收非直属下级的卡 + +- **WHEN** 一级代理(店铺 ID=10)尝试回收非直属下级店铺(ID=30,归属于店铺 ID=20)的卡 +- **THEN** 系统拒绝回收,返回错误"只能回收直属下级店铺的卡" + +#### Scenario: 拒绝代理主动退回 + +- **WHEN** 二级代理(店铺 ID=20)尝试将卡退回给上级店铺(ID=10) +- **THEN** 系统拒绝操作,返回错误"不能主动退回卡给上级,请联系上级进行回收" + +#### Scenario: 拒绝回收已绑定设备的卡 + +- **WHEN** 用户尝试回收已绑定设备的卡 +- **THEN** 系统拒绝回收,返回错误"已绑定设备的卡不能单独回收" diff --git a/openspec/changes/archive/2026-01-24-add-standalone-card-allocation/tasks.md b/openspec/changes/archive/2026-01-24-add-standalone-card-allocation/tasks.md new file mode 100644 index 0000000..6cfc5c7 --- /dev/null +++ b/openspec/changes/archive/2026-01-24-add-standalone-card-allocation/tasks.md @@ -0,0 +1,76 @@ +# Tasks: 单卡资产分配功能 + +## 1. DTO 定义 + +- [x] 1.1 创建 AllocateStandaloneCardsRequest DTO(支持三种选卡方式) +- [x] 1.2 创建 AllocateStandaloneCardsResponse DTO(返回成功/失败统计) +- [x] 1.3 创建 RecallStandaloneCardsRequest DTO +- [x] 1.4 创建 RecallStandaloneCardsResponse DTO +- [x] 1.5 创建 ListAssetAllocationRecordRequest DTO +- [x] 1.6 创建 AssetAllocationRecordResponse DTO +- [x] 1.7 创建 ListAssetAllocationRecordResponse DTO + +## 2. Store 层 + +- [x] 2.1 创建 AssetAllocationRecordStore + - [x] 2.1.1 实现 Create 方法 + - [x] 2.1.2 实现 BatchCreate 方法 + - [x] 2.1.3 实现 GetByID 方法 + - [x] 2.1.4 实现 List 方法(支持筛选和分页) +- [x] 2.2 实现 IotCardStore.BatchUpdateShopIDAndStatus 方法 +- [x] 2.3 实现 IotCardStore.GetStandaloneByICCIDRange 方法(号段查询) +- [x] 2.4 实现 IotCardStore.GetStandaloneByFilters 方法(筛选条件查询,排除已绑定设备的卡) +- [x] 2.5 实现 IotCardStore.GetByICCIDs 方法 +- [x] 2.6 实现 IotCardStore.GetBoundCardIDs 方法 + +## 3. Service 层 + +- [x] 3.1 创建 AssetAllocationRecordService + - [x] 3.1.1 实现 List 方法 + - [x] 3.1.2 实现 GetByID 方法 +- [x] 3.2 实现 IotCardService.AllocateCards 方法 + - [x] 3.2.1 校验目标店铺是当前用户的直属下级 + - [x] 3.2.2 根据选卡方式获取待分配的卡列表 + - [x] 3.2.3 校验卡是单卡(未绑定设备) + - [x] 3.2.4 校验卡状态和所有权 + - [x] 3.2.5 批量更新 shop_id 和 status + - [x] 3.2.6 创建分配记录 +- [x] 3.3 实现 IotCardService.RecallCards 方法 + - [x] 3.3.1 校验来源店铺是当前用户的直属下级 + - [x] 3.3.2 根据选卡方式获取待回收的卡列表 + - [x] 3.3.3 校验卡是单卡(未绑定设备) + - [x] 3.3.4 批量更新 shop_id(平台→NULL,店铺→回收方ID)和 status + - [x] 3.3.5 创建回收记录 + +## 4. Handler 层 + +- [x] 4.1 创建 AssetAllocationRecordHandler + - [x] 4.1.1 实现 List 方法 + - [x] 4.1.2 实现 GetByID 方法 +- [x] 4.2 实现 IotCardHandler.AllocateCards 方法 +- [x] 4.3 实现 IotCardHandler.RecallCards 方法 + +## 5. 路由注册 + +- [x] 5.1 注册 POST /api/admin/iot-cards/standalone/allocate +- [x] 5.2 注册 POST /api/admin/iot-cards/standalone/recall +- [x] 5.3 注册 GET /api/admin/asset-allocation-records +- [x] 5.4 注册 GET /api/admin/asset-allocation-records/:id +- [x] 5.5 更新 Bootstrap(handlers.go、services.go、stores.go、types.go) +- [x] 5.6 更新文档生成器(cmd/api/docs.go 和 cmd/gendocs/main.go) + +## 6. 测试 + +- [x] 6.1 AssetAllocationRecordStore 单元测试 +- [x] 6.2 IotCardStore.BatchUpdateShopIDAndStatus 单元测试 +- [x] 6.3 IotCardStore.GetStandaloneByICCIDRange 单元测试 +- [x] 6.4 IotCardStore.GetStandaloneByFilters 单元测试 +- [x] 6.5 IotCardStore.GetByICCIDs 单元测试 +- [x] 6.6 IotCardStore.GetBoundCardIDs 单元测试 +- [x] 6.7 分配 API 集成测试(TestStandaloneCardAllocation_AllocateByList) +- [x] 6.8 回收 API 集成测试(TestStandaloneCardAllocation_Recall) +- [x] 6.9 分配记录查询 API 集成测试(TestAssetAllocationRecord_List) + +## 7. 文档更新 + +- [x] 7.1 同步 delta spec 到主规范 diff --git a/openspec/specs/asset-allocation-record/spec.md b/openspec/specs/asset-allocation-record/spec.md new file mode 100644 index 0000000..7cdc7a6 --- /dev/null +++ b/openspec/specs/asset-allocation-record/spec.md @@ -0,0 +1,107 @@ +# Asset Allocation Record + +## Purpose + +管理资产(IoT 卡、设备)在平台与代理商之间的流转记录,支持分配和回收操作的完整追溯。 + +## Requirements + +### Requirement: 资产分配记录查询 + +系统 SHALL 提供资产分配记录的查询功能,支持查看卡和设备在平台与代理商之间的流转历史。 + +**记录类型**: +- `allocate`: 分配记录(上级分配给下级) +- `recall`: 回收记录(上级从下级回收) + +**资产类型**: +- `iot_card`: 物联网卡(单卡) +- `device`: 设备(未来扩展) + +**查询条件**: +- `allocation_type`(可选): 分配类型,枚举值 "allocate" | "recall" +- `asset_type`(可选): 资产类型,枚举值 "iot_card" | "device" +- `asset_identifier`(可选): 资产标识符(ICCID 或设备号),模糊匹配 +- `allocation_no`(可选): 分配单号,精确匹配 +- `from_shop_id`(可选): 来源店铺 ID +- `to_shop_id`(可选): 目标店铺 ID +- `operator_id`(可选): 操作人 ID +- `created_at_start`(可选): 创建时间起始 +- `created_at_end`(可选): 创建时间结束 + +**分页**: +- 默认每页 20 条,最大每页 100 条 +- 返回总记录数和总页数 + +**数据权限**: +- 平台用户可查看所有记录 +- 代理用户只能查看与自己店铺相关的记录(作为来源或目标) + +**API 端点**: `GET /api/admin/asset-allocation-records` + +**响应字段**: +- `id`: 记录 ID +- `allocation_no`: 分配单号 +- `allocation_type`: 分配类型 +- `allocation_type_name`: 分配类型名称(分配/回收) +- `asset_type`: 资产类型 +- `asset_type_name`: 资产类型名称(物联网卡/设备) +- `asset_id`: 资产 ID +- `asset_identifier`: 资产标识符 +- `from_owner_type`: 来源所有者类型 +- `from_owner_id`: 来源所有者 ID +- `from_owner_name`: 来源所有者名称 +- `to_owner_type`: 目标所有者类型 +- `to_owner_id`: 目标所有者 ID +- `to_owner_name`: 目标所有者名称 +- `operator_id`: 操作人 ID +- `operator_name`: 操作人名称 +- `remark`: 备注 +- `created_at`: 创建时间 + +#### Scenario: 查询所有分配记录 + +- **WHEN** 平台管理员查询分配记录列表,不带任何筛选条件 +- **THEN** 系统返回所有分配和回收记录,按创建时间倒序排列 + +#### Scenario: 按资产类型筛选记录 + +- **WHEN** 管理员查询资产类型为 "iot_card" 的记录 +- **THEN** 系统只返回物联网卡的分配/回收记录,不包含设备记录 + +#### Scenario: 按分配类型筛选记录 + +- **WHEN** 管理员查询分配类型为 "allocate" 的记录 +- **THEN** 系统只返回分配记录,不包含回收记录 + +#### Scenario: 按 ICCID 模糊查询 + +- **WHEN** 管理员输入 asset_identifier = "8986001" +- **THEN** 系统返回 ICCID 包含 "8986001" 的所有分配记录 + +#### Scenario: 代理查询自己相关的记录 + +- **WHEN** 代理用户(店铺 ID=10)查询分配记录 +- **THEN** 系统只返回 from_owner_id=10 或 to_owner_id=10 的记录 + +--- + +### Requirement: 资产分配记录详情 + +系统 SHALL 提供资产分配记录详情查询功能。 + +**API 端点**: `GET /api/admin/asset-allocation-records/:id` + +**响应**: +- 包含记录的所有字段 +- 关联卡 ID 列表(如果是设备分配,包含设备下的所有卡 ID) + +#### Scenario: 查询分配记录详情 + +- **WHEN** 管理员查询分配记录详情(ID=1) +- **THEN** 系统返回该记录的完整信息,包括来源/目标所有者名称、操作人名称等 + +#### Scenario: 查询不存在的记录 + +- **WHEN** 管理员查询不存在的分配记录(ID=999) +- **THEN** 系统返回 404 错误,提示"分配记录不存在" diff --git a/openspec/specs/iot-card/spec.md b/openspec/specs/iot-card/spec.md index b1d6d1c..4c02627 100644 --- a/openspec/specs/iot-card/spec.md +++ b/openspec/specs/iot-card/spec.md @@ -373,3 +373,133 @@ This capability supports: --- +### Requirement: 单卡分配功能 + +系统 SHALL 支持将单卡(未绑定设备的 IoT 卡)分配给直属下级店铺,实现资产所有权的层级流转。 + +**分配规则**: +- 只能分配给直属下级店铺,不可跨级分配 +- 平台(shop_id=NULL)只能分配状态为在库(status=1)的卡 +- 代理店铺可以分配状态为已分销(status=2)的卡(继续往下分销) +- 分配后状态变更:在库(1)→已分销(2),已分销(2)保持不变 +- 分配后 shop_id 变更为目标店铺 ID +- 分配不涉及费用,纯资产所有权转移 +- 分配后上级仍能看到和管理(通过数据权限机制) + +**选卡方式**(三选一): +- ICCID 列表:指定具体的 ICCID 列表 +- 号段范围:指定起始 ICCID 和结束 ICCID +- 筛选条件:按运营商、批次号、状态等条件批量选择 + +**API 端点**: `POST /api/admin/iot-cards/standalone/allocate` + +**请求参数**: +- `to_shop_id`(必填): 目标店铺 ID +- `selection_type`(必填): 选卡方式,枚举值 "list" | "range" | "filter" +- `iccids`(selection_type=list 时必填): ICCID 列表 +- `iccid_start`(selection_type=range 时必填): 起始 ICCID +- `iccid_end`(selection_type=range 时必填): 结束 ICCID +- `carrier_id`(selection_type=filter 时可选): 运营商 ID +- `batch_no`(selection_type=filter 时可选): 批次号 +- `status`(selection_type=filter 时可选): 卡状态 +- `remark`(可选): 备注 + +**响应**: +- `total_count`: 待分配总数 +- `success_count`: 成功数 +- `fail_count`: 失败数 +- `failed_items`: 失败项列表(包含 ICCID 和失败原因) + +#### Scenario: 平台通过 ICCID 列表分配单卡给一级代理 + +- **WHEN** 平台管理员选择 3 张在库单卡(ICCID 列表),分配给一级代理店铺(ID=10) +- **THEN** 系统将这 3 张卡的 shop_id 更新为 10,status 从 1 变为 2,创建分配记录,返回成功数 3 + +#### Scenario: 平台通过号段范围批量分配单卡 + +- **WHEN** 平台管理员指定 ICCID 范围 "8986001000000000000" 至 "8986001000000000099",分配给一级代理店铺(ID=10) +- **THEN** 系统查询该范围内的所有在库单卡,批量更新 shop_id 和 status,创建分配记录 + +#### Scenario: 代理通过筛选条件分配单卡给下级 + +- **WHEN** 一级代理(店铺 ID=10)按条件筛选(运营商=电信,批次号=BATCH-001)自己的已分销卡,分配给二级代理店铺(ID=20) +- **THEN** 系统查询符合条件的卡,校验店铺 20 是店铺 10 的直属下级,批量更新 shop_id 为 20,status 保持 2 + +#### Scenario: 拒绝跨级分配 + +- **WHEN** 平台尝试将卡直接分配给二级代理店铺(非直属下级) +- **THEN** 系统拒绝分配,返回错误"只能分配给直属下级店铺" + +#### Scenario: 拒绝平台分配已分销的卡 + +- **WHEN** 平台尝试分配状态为已分销(status=2)的卡 +- **THEN** 系统拒绝分配,返回错误"在库状态的卡才能分配,请先回收" + +#### Scenario: 拒绝分配已绑定设备的卡 + +- **WHEN** 用户尝试分配已绑定设备的卡(在 device_sim_bindings 中 bind_status=1) +- **THEN** 系统拒绝分配,返回错误"已绑定设备的卡不能单独分配" + +--- + +### Requirement: 单卡回收功能 + +系统 SHALL 支持上级回收已分配给直属下级的单卡,将卡的所有权收回。 + +**回收规则**: +- 只有上级可以回收,代理不能主动退回给上级 +- 只能回收直属下级的卡,不可跨级回收 +- 平台回收:shop_id 变为 NULL,status 变为 1(在库) +- 店铺回收:shop_id 变为执行回收的店铺 ID,status 保持 2(已分销) +- 只能回收单卡(未绑定设备的卡) + +**选卡方式**(与分配相同,三选一): +- ICCID 列表 +- 号段范围 +- 筛选条件 + +**API 端点**: `POST /api/admin/iot-cards/standalone/recall` + +**请求参数**: +- `from_shop_id`(必填): 来源店铺 ID(被回收方) +- `selection_type`(必填): 选卡方式,枚举值 "list" | "range" | "filter" +- `iccids`(selection_type=list 时必填): ICCID 列表 +- `iccid_start`(selection_type=range 时必填): 起始 ICCID +- `iccid_end`(selection_type=range 时必填): 结束 ICCID +- `carrier_id`(selection_type=filter 时可选): 运营商 ID +- `batch_no`(selection_type=filter 时可选): 批次号 +- `remark`(可选): 备注 + +**响应**: +- `total_count`: 待回收总数 +- `success_count`: 成功数 +- `fail_count`: 失败数 +- `failed_items`: 失败项列表 + +#### Scenario: 平台回收一级代理的单卡 + +- **WHEN** 平台管理员选择一级代理店铺(ID=10)的 5 张单卡进行回收 +- **THEN** 系统将这 5 张卡的 shop_id 更新为 NULL,status 从 2 变为 1,创建回收记录 + +#### Scenario: 一级代理回收二级代理的单卡 + +- **WHEN** 一级代理(店铺 ID=10)选择二级代理店铺(ID=20)的 3 张单卡进行回收 +- **THEN** 系统将这 3 张卡的 shop_id 更新为 10,status 保持 2,创建回收记录 + +#### Scenario: 拒绝回收非直属下级的卡 + +- **WHEN** 一级代理(店铺 ID=10)尝试回收非直属下级店铺(ID=30,归属于店铺 ID=20)的卡 +- **THEN** 系统拒绝回收,返回错误"只能回收直属下级店铺的卡" + +#### Scenario: 拒绝代理主动退回 + +- **WHEN** 二级代理(店铺 ID=20)尝试将卡退回给上级店铺(ID=10) +- **THEN** 系统拒绝操作,返回错误"不能主动退回卡给上级,请联系上级进行回收" + +#### Scenario: 拒绝回收已绑定设备的卡 + +- **WHEN** 用户尝试回收已绑定设备的卡 +- **THEN** 系统拒绝回收,返回错误"已绑定设备的卡不能单独回收" + +--- + diff --git a/pkg/errors/codes.go b/pkg/errors/codes.go index db4407e..c42f55b 100644 --- a/pkg/errors/codes.go +++ b/pkg/errors/codes.go @@ -59,6 +59,15 @@ const ( CodeWithdrawalNotFound = 1052 // 提现申请不存在 CodeWalletNotFound = 1053 // 钱包不存在 + // IoT 卡相关错误 (1070-1089) + CodeIotCardNotFound = 1070 // IoT 卡不存在 + CodeIotCardBoundToDevice = 1071 // IoT 卡已绑定设备 + CodeIotCardStatusNotAllowed = 1072 // 卡状态不允许此操作 + CodeAssetAllocationRecordNotFound = 1073 // 分配记录不存在 + CodeNotDirectSubordinate = 1074 // 非直属下级店铺 + CodeCannotAllocateToSelf = 1075 // 不能分配给自己 + CodeCannotRecallFromSelf = 1076 // 不能从自己回收 + // 服务端错误 (2000-2999) -> 5xx HTTP 状态码 CodeInternalError = 2001 // 内部服务器错误 CodeDatabaseError = 2002 // 数据库错误 @@ -114,6 +123,13 @@ var allErrorCodes = []int{ CodeInsufficientBalance, CodeWithdrawalNotFound, CodeWalletNotFound, + CodeIotCardNotFound, + CodeIotCardBoundToDevice, + CodeIotCardStatusNotAllowed, + CodeAssetAllocationRecordNotFound, + CodeNotDirectSubordinate, + CodeCannotAllocateToSelf, + CodeCannotRecallFromSelf, CodeInternalError, CodeDatabaseError, CodeRedisError, @@ -132,55 +148,62 @@ func init() { // errorMessages 错误消息映射表(中文) var errorMessages = map[int]string{ - CodeSuccess: "成功", - CodeInvalidParam: "参数验证失败", - CodeMissingToken: "缺失认证令牌", - CodeInvalidToken: "无效或过期的令牌", - CodeUnauthorized: "未授权访问", - CodeForbidden: "禁止访问", - CodeNotFound: "资源未找到", - CodeConflict: "资源冲突", - CodeTooManyRequests: "请求过多,请稍后重试", - CodeRequestTooLarge: "请求体过大", - CodeAccountNotFound: "账号不存在", - CodeAccountDisabled: "账号已禁用", - CodeAccountDeleted: "账号已删除", - CodeUsernameExists: "用户名已存在", - CodePhoneExists: "手机号已存在", - CodeInvalidPassword: "密码格式不正确", - CodePasswordTooWeak: "密码强度不足", - CodeParentIDRequired: "非 root 用户必须提供上级账号", - CodeInvalidParentID: "上级账号不存在或无效", - CodeCannotModifyParent: "禁止修改上级账号", - CodeCannotModifyUserType: "禁止修改用户类型", - CodeRoleNotFound: "角色不存在", - CodeRoleNameExists: "角色名称已存在", - CodePermissionNotFound: "权限不存在", - CodePermCodeExists: "权限编码已存在", - CodeInvalidPermCode: "权限编码格式不正确(应为 module:action 格式)", - CodeRoleAlreadyAssigned: "角色已分配", - CodePermAlreadyAssigned: "权限已分配", - CodeShopNotFound: "店铺不存在", - CodeShopCodeExists: "店铺编号已存在", - CodeShopLevelExceeded: "店铺层级不能超过 7 级", - CodeEnterpriseNotFound: "企业不存在", - CodeEnterpriseCodeExists: "企业编号已存在", - CodeCustomerNotFound: "个人客户不存在", - CodeCustomerPhoneExists: "个人客户手机号已存在", - CodeInvalidStatus: "状态不允许此操作", - CodeInsufficientBalance: "余额不足", - CodeWithdrawalNotFound: "提现申请不存在", - CodeWalletNotFound: "钱包不存在", - CodeInvalidCredentials: "用户名或密码错误", - CodeAccountLocked: "账号已锁定", - CodePasswordExpired: "密码已过期", - CodeInvalidOldPassword: "旧密码错误", - CodeInternalError: "内部服务器错误", - CodeDatabaseError: "数据库错误", - CodeRedisError: "缓存服务错误", - CodeServiceUnavailable: "服务暂时不可用", - CodeTimeout: "请求超时", - CodeTaskQueueError: "任务队列错误", + CodeSuccess: "成功", + CodeInvalidParam: "参数验证失败", + CodeMissingToken: "缺失认证令牌", + CodeInvalidToken: "无效或过期的令牌", + CodeUnauthorized: "未授权访问", + CodeForbidden: "禁止访问", + CodeNotFound: "资源未找到", + CodeConflict: "资源冲突", + CodeTooManyRequests: "请求过多,请稍后重试", + CodeRequestTooLarge: "请求体过大", + CodeAccountNotFound: "账号不存在", + CodeAccountDisabled: "账号已禁用", + CodeAccountDeleted: "账号已删除", + CodeUsernameExists: "用户名已存在", + CodePhoneExists: "手机号已存在", + CodeInvalidPassword: "密码格式不正确", + CodePasswordTooWeak: "密码强度不足", + CodeParentIDRequired: "非 root 用户必须提供上级账号", + CodeInvalidParentID: "上级账号不存在或无效", + CodeCannotModifyParent: "禁止修改上级账号", + CodeCannotModifyUserType: "禁止修改用户类型", + CodeRoleNotFound: "角色不存在", + CodeRoleNameExists: "角色名称已存在", + CodePermissionNotFound: "权限不存在", + CodePermCodeExists: "权限编码已存在", + CodeInvalidPermCode: "权限编码格式不正确(应为 module:action 格式)", + CodeRoleAlreadyAssigned: "角色已分配", + CodePermAlreadyAssigned: "权限已分配", + CodeShopNotFound: "店铺不存在", + CodeShopCodeExists: "店铺编号已存在", + CodeShopLevelExceeded: "店铺层级不能超过 7 级", + CodeEnterpriseNotFound: "企业不存在", + CodeEnterpriseCodeExists: "企业编号已存在", + CodeCustomerNotFound: "个人客户不存在", + CodeCustomerPhoneExists: "个人客户手机号已存在", + CodeInvalidStatus: "状态不允许此操作", + CodeInsufficientBalance: "余额不足", + CodeWithdrawalNotFound: "提现申请不存在", + CodeWalletNotFound: "钱包不存在", + CodeIotCardNotFound: "IoT 卡不存在", + CodeIotCardBoundToDevice: "IoT 卡已绑定设备,不能单独操作", + CodeIotCardStatusNotAllowed: "卡状态不允许此操作", + CodeAssetAllocationRecordNotFound: "分配记录不存在", + CodeNotDirectSubordinate: "只能操作直属下级店铺", + CodeCannotAllocateToSelf: "不能分配给自己", + CodeCannotRecallFromSelf: "不能从自己回收", + CodeInvalidCredentials: "用户名或密码错误", + CodeAccountLocked: "账号已锁定", + CodePasswordExpired: "密码已过期", + CodeInvalidOldPassword: "旧密码错误", + CodeInternalError: "内部服务器错误", + CodeDatabaseError: "数据库错误", + CodeRedisError: "缓存服务错误", + CodeServiceUnavailable: "服务暂时不可用", + CodeTimeout: "请求超时", + CodeTaskQueueError: "任务队列错误", } // GetMessage 获取错误码对应的消息 diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go index 35d9156..32b36bf 100644 --- a/pkg/errors/errors.go +++ b/pkg/errors/errors.go @@ -13,6 +13,16 @@ var ( ErrTooManyRequests = errors.New("too many requests") ) +// 预定义业务错误(常用错误可直接引用) +var ( + ErrAssetAllocationRecordNotFound = &AppError{Code: CodeAssetAllocationRecordNotFound, Message: "分配记录不存在"} + ErrNotDirectSubordinate = &AppError{Code: CodeNotDirectSubordinate, Message: "只能操作直属下级店铺"} + ErrIotCardBoundToDevice = &AppError{Code: CodeIotCardBoundToDevice, Message: "IoT 卡已绑定设备,不能单独操作"} + ErrIotCardStatusNotAllowed = &AppError{Code: CodeIotCardStatusNotAllowed, Message: "卡状态不允许此操作"} + ErrCannotAllocateToSelf = &AppError{Code: CodeCannotAllocateToSelf, Message: "不能分配给自己"} + ErrCannotRecallFromSelf = &AppError{Code: CodeCannotRecallFromSelf, Message: "不能从自己回收"} +) + // AppError 表示带错误码的应用错误 type AppError struct { Code int // 应用错误码 diff --git a/pkg/gorm/callback.go b/pkg/gorm/callback.go index e3e2e6a..5bc9e00 100644 --- a/pkg/gorm/callback.go +++ b/pkg/gorm/callback.go @@ -2,6 +2,7 @@ package gorm import ( "context" + "reflect" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/logger" @@ -236,13 +237,47 @@ func RegisterDataPermissionCallback(db *gorm.DB, shopStore ShopStoreInterface) e func RegisterSetCreatorUpdaterCallback(db *gorm.DB) error { err := db.Callback().Create().Before("gorm:create").Register("set_creator_updater", func(tx *gorm.DB) { ctx := tx.Statement.Context - if userID, ok := tx.Statement.Context.Value(constants.ContextKeyUserID).(uint); ok { - if f := tx.Statement.Schema; f != nil { - if c, ok := f.FieldsByName["Creator"]; ok { - _ = c.Set(ctx, tx.Statement.ReflectValue, userID) + userID, ok := tx.Statement.Context.Value(constants.ContextKeyUserID).(uint) + if !ok || tx.Statement.Schema == nil { + return + } + + creatorField, hasCreator := tx.Statement.Schema.FieldsByName["Creator"] + updaterField, hasUpdater := tx.Statement.Schema.FieldsByName["Updater"] + if !hasCreator && !hasUpdater { + return + } + + rv := tx.Statement.ReflectValue + switch rv.Kind() { + case reflect.Slice, reflect.Array: + for i := 0; i < rv.Len(); i++ { + elem := rv.Index(i) + if elem.Kind() == reflect.Ptr { + elem = elem.Elem() } - if u, ok := f.FieldsByName["Updater"]; ok { - _ = u.Set(ctx, tx.Statement.ReflectValue, userID) + if hasCreator { + _ = creatorField.Set(ctx, elem, userID) + } + if hasUpdater { + _ = updaterField.Set(ctx, elem, userID) + } + } + case reflect.Struct: + if hasCreator { + _ = creatorField.Set(ctx, rv, userID) + } + if hasUpdater { + _ = updaterField.Set(ctx, rv, userID) + } + case reflect.Ptr: + elem := rv.Elem() + if elem.Kind() == reflect.Struct { + if hasCreator { + _ = creatorField.Set(ctx, elem, userID) + } + if hasUpdater { + _ = updaterField.Set(ctx, elem, userID) } } } diff --git a/tests/integration/standalone_card_allocation_test.go b/tests/integration/standalone_card_allocation_test.go new file mode 100644 index 0000000..ced1bfc --- /dev/null +++ b/tests/integration/standalone_card_allocation_test.go @@ -0,0 +1,426 @@ +package integration + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http/httptest" + "testing" + "time" + + "github.com/break/junhong_cmp_fiber/internal/bootstrap" + internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/routes" + "github.com/break/junhong_cmp_fiber/pkg/auth" + "github.com/break/junhong_cmp_fiber/pkg/config" + "github.com/break/junhong_cmp_fiber/pkg/constants" + pkggorm "github.com/break/junhong_cmp_fiber/pkg/gorm" + "github.com/break/junhong_cmp_fiber/pkg/queue" + "github.com/break/junhong_cmp_fiber/pkg/response" + "github.com/break/junhong_cmp_fiber/tests/testutil" + "github.com/gofiber/fiber/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type allocationTestEnv struct { + db *gorm.DB + rdb *redis.Client + tokenManager *auth.TokenManager + app *fiber.App + adminToken string + agentToken string + adminID uint + agentID uint + shopID uint + subShopID uint + t *testing.T +} + +func setupAllocationTestEnv(t *testing.T) *allocationTestEnv { + t.Helper() + + t.Setenv("CONFIG_ENV", "dev") + t.Setenv("CONFIG_PATH", "../../configs/config.dev.yaml") + cfg, err := config.Load() + require.NoError(t, err) + err = config.Set(cfg) + require.NoError(t, err) + + zapLogger, _ := zap.NewDevelopment() + + dsn := "host=cxd.whcxd.cn port=16159 user=erp_pgsql password=erp_2025 dbname=junhong_cmp_test sslmode=disable TimeZone=Asia/Shanghai" + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + require.NoError(t, err) + + db.Exec("DELETE FROM tb_asset_allocation_record WHERE asset_identifier LIKE 'ALLOC_TEST%'") + db.Exec("DELETE FROM tb_iot_card WHERE iccid LIKE 'ALLOC_TEST%'") + db.Exec("DROP INDEX IF EXISTS uk_asset_allocation_no") + + rdb := redis.NewClient(&redis.Options{ + Addr: "cxd.whcxd.cn:16299", + Password: "cpNbWtAaqgo1YJmbMp3h", + DB: 15, + }) + + ctx := context.Background() + err = rdb.Ping(ctx).Err() + require.NoError(t, err) + + testPrefix := fmt.Sprintf("test:%s:", t.Name()) + keys, _ := rdb.Keys(ctx, testPrefix+"*").Result() + if len(keys) > 0 { + rdb.Del(ctx, keys...) + } + + tokenManager := auth.NewTokenManager(rdb, 24*time.Hour, 7*24*time.Hour) + superAdmin := testutil.CreateSuperAdmin(t, db) + adminToken, _ := testutil.GenerateTestToken(t, rdb, superAdmin, "web") + + shop := &model.Shop{ + ShopName: fmt.Sprintf("测试店铺_%d", time.Now().UnixNano()), + ShopCode: fmt.Sprintf("ALLOC_SHOP_%d", time.Now().UnixNano()), + ContactName: "测试联系人", + ContactPhone: "13800000001", + Status: 1, + } + require.NoError(t, db.Create(shop).Error) + + subShop := &model.Shop{ + ShopName: fmt.Sprintf("测试下级店铺_%d", time.Now().UnixNano()), + ShopCode: fmt.Sprintf("ALLOC_SUB_%d", time.Now().UnixNano()), + ParentID: &shop.ID, + Level: 2, + ContactName: "下级联系人", + ContactPhone: "13800000002", + Status: 1, + } + require.NoError(t, db.Create(subShop).Error) + + agentAccount := &model.Account{ + Username: fmt.Sprintf("agent_alloc_%d", time.Now().UnixNano()), + Phone: fmt.Sprintf("139%08d", time.Now().UnixNano()%100000000), + Password: "hashed_password", + UserType: constants.UserTypeAgent, + ShopID: &shop.ID, + Status: 1, + } + require.NoError(t, db.Create(agentAccount).Error) + agentToken, _ := testutil.GenerateTestToken(t, rdb, agentAccount, "web") + + queueClient := queue.NewClient(rdb, zapLogger) + + deps := &bootstrap.Dependencies{ + DB: db, + Redis: rdb, + Logger: zapLogger, + TokenManager: tokenManager, + QueueClient: queueClient, + } + + result, err := bootstrap.Bootstrap(deps) + require.NoError(t, err) + + app := fiber.New(fiber.Config{ + ErrorHandler: internalMiddleware.ErrorHandler(zapLogger), + }) + + routes.RegisterRoutes(app, result.Handlers, result.Middlewares) + + return &allocationTestEnv{ + db: db, + rdb: rdb, + tokenManager: tokenManager, + app: app, + adminToken: adminToken, + agentToken: agentToken, + adminID: superAdmin.ID, + agentID: agentAccount.ID, + shopID: shop.ID, + subShopID: subShop.ID, + t: t, + } +} + +func (e *allocationTestEnv) teardown() { + e.db.Exec("DELETE FROM tb_iot_card WHERE iccid LIKE 'ALLOC_TEST%'") + e.db.Exec("DELETE FROM tb_asset_allocation_record WHERE asset_identifier LIKE 'ALLOC_TEST%'") + e.db.Exec("DELETE FROM tb_shop WHERE shop_code LIKE 'ALLOC_%'") + e.db.Exec("DELETE FROM tb_account WHERE username LIKE 'agent_alloc_%'") + + ctx := context.Background() + testPrefix := fmt.Sprintf("test:%s:", e.t.Name()) + keys, _ := e.rdb.Keys(ctx, testPrefix+"*").Result() + if len(keys) > 0 { + e.rdb.Del(ctx, keys...) + } + + e.rdb.Close() +} + +func TestStandaloneCardAllocation_AllocateByList(t *testing.T) { + env := setupAllocationTestEnv(t) + defer env.teardown() + + cards := []*model.IotCard{ + {ICCID: "ALLOC_TEST001", CardType: "data_card", CarrierID: 1, Status: constants.IotCardStatusInStock}, + {ICCID: "ALLOC_TEST002", CardType: "data_card", CarrierID: 1, Status: constants.IotCardStatusInStock}, + {ICCID: "ALLOC_TEST003", CardType: "data_card", CarrierID: 1, Status: constants.IotCardStatusInStock}, + } + for _, card := range cards { + require.NoError(t, env.db.Create(card).Error) + } + + t.Run("平台分配卡给一级店铺", func(t *testing.T) { + reqBody := map[string]interface{}{ + "to_shop_id": env.shopID, + "selection_type": "list", + "iccids": []string{"ALLOC_TEST001", "ALLOC_TEST002"}, + "remark": "测试分配", + } + bodyBytes, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/admin/iot-cards/standalone/allocate", bytes.NewReader(bodyBytes)) + req.Header.Set("Authorization", "Bearer "+env.adminToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := env.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + + t.Logf("Allocate response: code=%d, message=%s, data=%v", result.Code, result.Message, result.Data) + + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, 0, result.Code, "应返回成功: %s", result.Message) + + if result.Data != nil { + dataMap := result.Data.(map[string]interface{}) + assert.Equal(t, float64(2), dataMap["total_count"]) + assert.Equal(t, float64(2), dataMap["success_count"]) + assert.Equal(t, float64(0), dataMap["fail_count"]) + } + + ctx := pkggorm.SkipDataPermission(context.Background()) + var updatedCards []model.IotCard + env.db.WithContext(ctx).Where("iccid IN ?", []string{"ALLOC_TEST001", "ALLOC_TEST002"}).Find(&updatedCards) + for _, card := range updatedCards { + assert.Equal(t, env.shopID, *card.ShopID, "卡应分配给目标店铺") + assert.Equal(t, constants.IotCardStatusDistributed, card.Status, "状态应为已分销") + } + + var recordCount int64 + env.db.WithContext(ctx).Model(&model.AssetAllocationRecord{}). + Where("asset_identifier IN ?", []string{"ALLOC_TEST001", "ALLOC_TEST002"}). + Count(&recordCount) + assert.Equal(t, int64(2), recordCount, "应创建2条分配记录") + }) + + t.Run("代理分配卡给下级店铺", func(t *testing.T) { + reqBody := map[string]interface{}{ + "to_shop_id": env.subShopID, + "selection_type": "list", + "iccids": []string{"ALLOC_TEST001"}, + "remark": "代理分配测试", + } + bodyBytes, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/admin/iot-cards/standalone/allocate", bytes.NewReader(bodyBytes)) + req.Header.Set("Authorization", "Bearer "+env.agentToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := env.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + + t.Logf("Agent allocate response: code=%d, message=%s", result.Code, result.Message) + + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, 0, result.Code, "代理应能分配给下级: %s", result.Message) + }) + + t.Run("分配不存在的卡应返回空结果", func(t *testing.T) { + reqBody := map[string]interface{}{ + "to_shop_id": env.shopID, + "selection_type": "list", + "iccids": []string{"NOT_EXISTS_001", "NOT_EXISTS_002"}, + } + bodyBytes, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/admin/iot-cards/standalone/allocate", bytes.NewReader(bodyBytes)) + req.Header.Set("Authorization", "Bearer "+env.adminToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := env.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + + assert.Equal(t, 0, result.Code) + if result.Data != nil { + dataMap := result.Data.(map[string]interface{}) + assert.Equal(t, float64(0), dataMap["total_count"]) + } + }) +} + +func TestStandaloneCardAllocation_Recall(t *testing.T) { + env := setupAllocationTestEnv(t) + defer env.teardown() + + shopID := env.shopID + cards := []*model.IotCard{ + {ICCID: "ALLOC_TEST101", CardType: "data_card", CarrierID: 1, Status: constants.IotCardStatusDistributed, ShopID: &shopID}, + {ICCID: "ALLOC_TEST102", CardType: "data_card", CarrierID: 1, Status: constants.IotCardStatusDistributed, ShopID: &shopID}, + } + for _, card := range cards { + require.NoError(t, env.db.Create(card).Error) + } + + t.Run("平台回收卡", func(t *testing.T) { + reqBody := map[string]interface{}{ + "from_shop_id": env.shopID, + "selection_type": "list", + "iccids": []string{"ALLOC_TEST101"}, + "remark": "平台回收测试", + } + bodyBytes, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/admin/iot-cards/standalone/recall", bytes.NewReader(bodyBytes)) + req.Header.Set("Authorization", "Bearer "+env.adminToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := env.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + + t.Logf("Recall response: code=%d, message=%s, data=%v", result.Code, result.Message, result.Data) + + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, 0, result.Code, "应返回成功: %s", result.Message) + + ctx := pkggorm.SkipDataPermission(context.Background()) + var recalledCard model.IotCard + env.db.WithContext(ctx).Where("iccid = ?", "ALLOC_TEST101").First(&recalledCard) + assert.Nil(t, recalledCard.ShopID, "平台回收后shop_id应为NULL") + assert.Equal(t, constants.IotCardStatusInStock, recalledCard.Status, "状态应恢复为在库") + }) +} + +func TestAssetAllocationRecord_List(t *testing.T) { + env := setupAllocationTestEnv(t) + defer env.teardown() + + fromShopID := env.shopID + records := []*model.AssetAllocationRecord{ + { + AllocationNo: fmt.Sprintf("AL%d001", time.Now().UnixNano()), + AllocationType: constants.AssetAllocationTypeAllocate, + AssetType: constants.AssetTypeIotCard, + AssetID: 1, + AssetIdentifier: "ALLOC_TEST_REC001", + FromOwnerType: constants.OwnerTypePlatform, + ToOwnerType: constants.OwnerTypeShop, + ToOwnerID: env.shopID, + OperatorID: env.adminID, + }, + { + AllocationNo: fmt.Sprintf("RC%d001", time.Now().UnixNano()), + AllocationType: constants.AssetAllocationTypeRecall, + AssetType: constants.AssetTypeIotCard, + AssetID: 2, + AssetIdentifier: "ALLOC_TEST_REC002", + FromOwnerType: constants.OwnerTypeShop, + FromOwnerID: &fromShopID, + ToOwnerType: constants.OwnerTypePlatform, + ToOwnerID: 0, + OperatorID: env.adminID, + }, + } + for _, record := range records { + require.NoError(t, env.db.Create(record).Error) + } + + t.Run("获取分配记录列表", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/admin/asset-allocation-records?page=1&page_size=20", nil) + req.Header.Set("Authorization", "Bearer "+env.adminToken) + + resp, err := env.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, 0, result.Code) + }) + + t.Run("按分配类型过滤", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/admin/asset-allocation-records?allocation_type=allocate", nil) + req.Header.Set("Authorization", "Bearer "+env.adminToken) + + resp, err := env.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, 0, result.Code) + }) + + t.Run("获取分配记录详情", func(t *testing.T) { + url := fmt.Sprintf("/api/admin/asset-allocation-records/%d", records[0].ID) + req := httptest.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+env.adminToken) + + resp, err := env.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, 0, result.Code) + }) + + t.Run("未认证请求应返回错误", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/admin/asset-allocation-records", nil) + + resp, err := env.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.NotEqual(t, 0, result.Code, "未认证请求应返回错误码") + }) +}