批量收回功能
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m1s

This commit is contained in:
2026-07-28 10:53:15 +08:00
parent 7c5b6ee036
commit 178cc45bc2
16 changed files with 117 additions and 71 deletions

View File

@@ -46,7 +46,7 @@ func (h *DeviceImportHandler) Import(c *fiber.Ctx) error {
return response.Success(c, result)
}
// CreateAllocation 创建单列 CSV 设备批量分配任务。
// CreateAllocation 创建单列 CSV 设备批量分配或回收任务。
// POST /api/admin/devices/import/allocations
func (h *DeviceImportHandler) CreateAllocation(c *fiber.Ctx) error {
var req dto.CreateDeviceBatchAllocationRequest

View File

@@ -13,8 +13,8 @@ type DeviceImportTask struct {
gorm.Model
BaseModel `gorm:"embedded"`
TaskNo string `gorm:"column:task_no;type:varchar(50);uniqueIndex:idx_device_import_task_no,where:deleted_at IS NULL;not null;comment:任务编号(唯一)" json:"task_no"`
OperationType string `gorm:"column:operation_type;type:varchar(32);not null;default:'import';comment:任务业务类型(import/assign_shop/assign_series)" json:"operation_type"`
TargetID *uint `gorm:"column:target_id;comment:批量分配目标店铺或套餐系列ID" json:"target_id"`
OperationType string `gorm:"column:operation_type;type:varchar(32);not null;default:'import';comment:任务业务类型(import/assign_shop/assign_series/recall)" json:"operation_type"`
TargetID *uint `gorm:"column:target_id;comment:批量分配目标店铺或套餐系列ID,回收任务为空" json:"target_id"`
OperatorType int `gorm:"column:operator_type;type:int;not null;default:0;comment:任务创建时操作者类型快照" json:"operator_type"`
OperatorShopID *uint `gorm:"column:operator_shop_id;comment:任务创建时操作者店铺ID快照" json:"operator_shop_id"`
BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"`

View File

@@ -14,14 +14,14 @@ type ImportDeviceResponse struct {
Message string `json:"message" description:"提示信息"`
}
// CreateDeviceBatchAllocationRequest 创建设备 CSV 批量分配任务请求。
// CreateDeviceBatchAllocationRequest 创建设备 CSV 批量操作任务请求。
type CreateDeviceBatchAllocationRequest struct {
FileKey string `json:"file_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"单列设备标识 CSV 对象存储 Key每行支持 VirtualNo、IMEI 或 SN"`
OperationType string `json:"operation_type" validate:"required,oneof=assign_shop assign_series" required:"true" enum:"assign_shop,assign_series" description:"操作类型 (assign_shop:分配目标代理, assign_series:设置套餐系列)"`
TargetID uint `json:"target_id" validate:"required,min=1" required:"true" minimum:"1" description:"目标代理店铺ID或套餐系列ID,由 operation_type 决定"`
OperationType string `json:"operation_type" validate:"required,oneof=assign_shop assign_series recall" required:"true" enum:"assign_shop,assign_series,recall" description:"操作类型 (assign_shop:分配目标代理, assign_series:设置套餐系列, recall:回收设备)"`
TargetID uint `json:"target_id,omitempty" validate:"omitempty,min=1" minimum:"1" description:"目标代理店铺ID或套餐系列IDrecall 时不传"`
}
// CreateDeviceBatchAllocationResponse 创建设备 CSV 批量分配任务响应。
// CreateDeviceBatchAllocationResponse 创建设备 CSV 批量操作任务响应。
type CreateDeviceBatchAllocationResponse struct {
TaskID uint `json:"task_id" description:"任务ID"`
TaskNo string `json:"task_no" description:"任务编号"`
@@ -32,7 +32,7 @@ type ListDeviceImportTaskRequest 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:"每页数量"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
OperationType string `json:"operation_type" query:"operation_type" validate:"omitempty,oneof=import assign_shop assign_series" enum:"import,assign_shop,assign_series" description:"任务业务类型 (import:导入设备, assign_shop:分配目标代理, assign_series:设置套餐系列)"`
OperationType string `json:"operation_type" query:"operation_type" validate:"omitempty,oneof=import assign_shop assign_series recall" enum:"import,assign_shop,assign_series,recall" description:"任务业务类型 (import:导入设备, assign_shop:分配目标代理, assign_series:设置套餐系列, recall:回收设备)"`
BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号(模糊查询)"`
StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"`
EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"`
@@ -41,9 +41,9 @@ type ListDeviceImportTaskRequest struct {
type DeviceImportTaskResponse struct {
ID uint `json:"id" description:"任务ID"`
TaskNo string `json:"task_no" description:"任务编号"`
OperationType string `json:"operation_type" description:"任务业务类型 (import:导入设备, assign_shop:分配目标代理, assign_series:设置套餐系列)"`
OperationType string `json:"operation_type" description:"任务业务类型 (import:导入设备, assign_shop:分配目标代理, assign_series:设置套餐系列, recall:回收设备)"`
OperationName string `json:"operation_name" description:"任务业务类型名称(中文)"`
TargetID *uint `json:"target_id,omitempty" description:"批量分配目标店铺或套餐系列ID"`
TargetID *uint `json:"target_id,omitempty" description:"批量分配目标店铺或套餐系列ID,回收任务为空"`
Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
StatusName string `json:"status_name" description:"任务状态名称(中文)"`
StatusText string `json:"status_text" description:"任务状态文本"`

View File

@@ -3,7 +3,7 @@ package dto
type GetUploadURLRequest struct {
FileName string `json:"file_name" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"文件名cards.csv"`
ContentType string `json:"content_type" validate:"omitempty,max=100" maxLength:"100" description:"文件 MIME 类型text/csv留空则自动推断"`
Purpose string `json:"purpose" validate:"required,oneof=iot_import export attachment batch_purchase device_batch_allocation" required:"true" enum:"iot_import,export,attachment,batch_purchase,device_batch_allocation" description:"文件用途 (iot_import:ICCID导入, export:数据导出, attachment:附件, batch_purchase:资产套餐批量订购CSV, device_batch_allocation:设备批量分配CSV)"`
Purpose string `json:"purpose" validate:"required,oneof=iot_import export attachment batch_purchase device_batch_allocation" required:"true" enum:"iot_import,export,attachment,batch_purchase,device_batch_allocation" description:"文件用途 (iot_import:ICCID导入, export:数据导出, attachment:附件, batch_purchase:资产套餐批量订购CSV, device_batch_allocation:设备批量分配或回收CSV)"`
}
type GetUploadURLResponse struct {

View File

@@ -125,8 +125,8 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp
})
Register(devices, doc, groupPath, "POST", "/import/allocations", importHandler.CreateAllocation, RouteSpec{
Summary: "创建CSV设备批量分配任务",
Description: "复用设备导入任务。CSV只能包含一列设备标识每行支持VirtualNo、IMEI或SN整批选择分配目标代理设置目标套餐系列。",
Summary: "创建CSV设备批量分配或回收任务",
Description: "复用设备导入任务。CSV只能包含一列设备标识每行支持VirtualNo、IMEI或SN整批分配目标代理设置目标套餐系列或回收设备。",
Tags: []string{"设备管理"},
Input: new(dto.CreateDeviceBatchAllocationRequest),
Output: new(dto.CreateDeviceBatchAllocationResponse),

View File

@@ -135,7 +135,7 @@ await api.post('/iot-cards/import', {
|---|------|-------------|
| iot_import | ICCID/设备导入 (Excel) | imports/YYYY/MM/DD/uuid.xlsx |
| batch_purchase | 资产套餐批量订购 (CSV) | batch-purchases/YYYY/MM/DD/uuid.csv |
| device_batch_allocation | 设备批量分配代理或套餐系列 (CSV) | device-batch-allocations/YYYY/MM/DD/uuid.csv |
| device_batch_allocation | 设备批量分配、设置套餐系列或回收 (CSV) | device-batch-allocations/YYYY/MM/DD/uuid.csv |
| export | 数据导出 | exports/YYYY/MM/DD/uuid.xlsx |
| attachment | 附件上传 | attachments/YYYY/MM/DD/uuid.ext |

View File

@@ -61,13 +61,15 @@ func newDeviceBatchAllocationAuditParams(taskID uint, taskNo string, req *dto.Cr
if req != nil {
afterData["file_key"] = req.FileKey
afterData["operation_type"] = req.OperationType
afterData["target_id"] = req.TargetID
if req.OperationType != constants.DeviceImportOperationRecall {
afterData["target_id"] = req.TargetID
}
}
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
return assetAuditSvc.BuildLogParams{
AssetID: taskID, AssetIdentifier: taskNo,
OperationType: constants.AssetAuditOpDeviceBatchTaskCreate,
OperationDesc: "创建设备CSV批量分配任务", ResultStatus: resultStatus,
OperationDesc: "创建设备CSV批量操作任务", ResultStatus: resultStatus,
ErrorCode: errorCode, ErrorMsg: errorMsg, AfterData: afterData,
}
}

View File

@@ -96,20 +96,24 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceReq
}, nil
}
// CreateBatchAllocationTask 复用设备导入任务创建单列 CSV 批量分配任务。
// CreateBatchAllocationTask 复用设备导入任务创建单列 CSV 批量操作任务。
func (s *Service) CreateBatchAllocationTask(ctx context.Context, req *dto.CreateDeviceBatchAllocationRequest) (*dto.CreateDeviceBatchAllocationResponse, error) {
userID := middleware.GetUserIDFromContext(ctx)
userType := middleware.GetUserTypeFromContext(ctx)
if userID == 0 || (userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent) {
appErr := errors.New(errors.CodeForbidden, "仅平台和代理后台账号可创建设备批量分配任务")
appErr := errors.New(errors.CodeForbidden, "仅平台和代理后台账号可创建设备CSV批量任务")
s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(0, "", req, constants.AssetAuditResultDenied, appErr))
return nil, appErr
}
if req == nil || !constants.IsDeviceImportOperation(req.OperationType) || req.OperationType == constants.DeviceImportOperationCreate || req.TargetID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "设备批量分配参数不合法")
if req == nil || !constants.IsDeviceImportOperation(req.OperationType) || req.OperationType == constants.DeviceImportOperationCreate {
return nil, errors.New(errors.CodeInvalidParam, "设备CSV批量任务参数不合法")
}
if (req.OperationType == constants.DeviceImportOperationRecall && req.TargetID != 0) ||
(req.OperationType != constants.DeviceImportOperationRecall && req.TargetID == 0) {
return nil, errors.New(errors.CodeInvalidParam, "设备CSV批量任务参数不合法")
}
if !strings.HasPrefix(req.FileKey, constants.DeviceBatchAllocationStoragePrefix+"/") || !strings.EqualFold(filepath.Ext(req.FileKey), ".csv") {
return nil, errors.New(errors.CodeInvalidParam, "设备批量分配文件必须是指定目录下的CSV文件")
return nil, errors.New(errors.CodeInvalidParam, "设备CSV批量文件必须是指定目录下的CSV文件")
}
taskNo := s.importTaskStore.GenerateTaskNo(ctx)
@@ -121,16 +125,19 @@ func (s *Service) CreateBatchAllocationTask(ctx context.Context, req *dto.Create
}
operatorShopID = &shopID
}
targetID := req.TargetID
var targetID *uint
if req.OperationType != constants.DeviceImportOperationRecall {
targetID = &req.TargetID
}
task := &model.DeviceImportTask{
TaskNo: taskNo, OperationType: req.OperationType, TargetID: &targetID,
TaskNo: taskNo, OperationType: req.OperationType, TargetID: targetID,
OperatorType: userType, OperatorShopID: operatorShopID,
Status: model.ImportTaskStatusPending, StorageKey: req.FileKey, FileName: filepath.Base(req.FileKey),
CreatorName: middleware.GetUsernameFromContext(ctx),
}
task.Creator, task.Updater = userID, userID
if err := s.importTaskStore.Create(ctx, task); err != nil {
appErr := errors.Wrap(errors.CodeDatabaseError, err, "创建设备批量分配任务失败")
appErr := errors.Wrap(errors.CodeDatabaseError, err, "创建设备CSV批量任务失败")
s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(0, taskNo, req, constants.AssetAuditResultFailed, appErr))
return nil, appErr
}
@@ -138,13 +145,13 @@ func (s *Service) CreateBatchAllocationTask(ctx context.Context, req *dto.Create
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeDeviceImport)),
asynq.Timeout(constants.DeviceBatchAllocationTaskTimeout)); err != nil {
_ = s.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败")
appErr := errors.Wrap(errors.CodeInternalError, err, "设备批量分配任务入队失败")
appErr := errors.Wrap(errors.CodeInternalError, err, "设备CSV批量任务入队失败")
s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(task.ID, taskNo, req, constants.AssetAuditResultFailed, appErr))
return nil, appErr
}
s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(task.ID, taskNo, req, constants.AssetAuditResultSuccess, nil))
return &dto.CreateDeviceBatchAllocationResponse{
TaskID: task.ID, TaskNo: task.TaskNo, Message: "设备批量分配任务已创建Worker 将异步处理CSV文件",
TaskID: task.ID, TaskNo: task.TaskNo, Message: "设备CSV批量任务已创建Worker 将异步处理CSV文件",
}, nil
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
@@ -23,8 +24,8 @@ type deviceBatchAllocationRow struct {
}
func (h *DeviceImportHandler) handleDeviceBatchAllocation(ctx context.Context, task *model.DeviceImportTask) error {
if h.allocationExecutor == nil || task.TargetID == nil || *task.TargetID == 0 {
_ = h.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "设备批量分配执行器或目标未配置")
if h.allocationExecutor == nil || (task.OperationType != constants.DeviceImportOperationRecall && (task.TargetID == nil || *task.TargetID == 0)) {
_ = h.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "设备CSV批量执行器或目标未配置")
return asynq.SkipRetry
}
rows, err := h.downloadDeviceBatchAllocationCSV(ctx, task)
@@ -32,9 +33,14 @@ func (h *DeviceImportHandler) handleDeviceBatchAllocation(ctx context.Context, t
_ = h.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, err.Error())
return asynq.SkipRetry
}
shopScope, err := h.resolveDeviceBatchShopScope(ctx, task)
if err != nil {
_ = h.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, err.Error())
return asynq.SkipRetry
}
workerCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{
UserID: task.Creator, UserType: task.OperatorType, Username: task.CreatorName,
ShopID: valueOrZero(task.OperatorShopID), SubordinateShopIDs: operatorDeviceShopScope(task.OperatorShopID),
ShopID: valueOrZero(task.OperatorShopID), SubordinateShopIDs: shopScope,
})
result, err := h.executeDeviceBatchAllocation(workerCtx, task, rows)
if err != nil {
@@ -43,7 +49,7 @@ func (h *DeviceImportHandler) handleDeviceBatchAllocation(ctx context.Context, t
}
_ = h.importTaskStore.UpdateResult(ctx, task.ID, len(rows), result.successCount, result.skipCount, result.failCount, 0, result.skippedItems, result.failedItems, nil)
if result.successCount == 0 && result.failCount > 0 {
_ = h.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "所有设备分配均失败")
_ = h.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "所有设备操作均失败")
} else {
_ = h.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusCompleted, "")
}
@@ -52,20 +58,20 @@ func (h *DeviceImportHandler) handleDeviceBatchAllocation(ctx context.Context, t
func (h *DeviceImportHandler) downloadDeviceBatchAllocationCSV(ctx context.Context, task *model.DeviceImportTask) ([]deviceBatchAllocationRow, error) {
if h.storageService == nil || task.StorageKey == "" {
return nil, errors.New(errors.CodeServiceUnavailable, "设备批量分配对象存储未配置")
return nil, errors.New(errors.CodeServiceUnavailable, "设备CSV批量对象存储未配置")
}
path, cleanup, err := h.storageService.DownloadToTemp(ctx, task.StorageKey)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "下载设备批量分配CSV失败")
return nil, errors.Wrap(errors.CodeInternalError, err, "下载设备批量CSV失败")
}
defer cleanup()
info, err := os.Stat(path)
if err != nil || info.Size() > constants.DeviceBatchAllocationMaxFileSize {
return nil, errors.New(errors.CodeInvalidParam, "设备批量分配CSV不存在或超过10MB")
return nil, errors.New(errors.CodeInvalidParam, "设备批量CSV不存在或超过10MB")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "读取设备批量分配CSV失败")
return nil, errors.Wrap(errors.CodeInternalError, err, "读取设备批量CSV失败")
}
return parseDeviceBatchAllocationCSV(data)
}
@@ -81,22 +87,22 @@ func parseDeviceBatchAllocationCSV(data []byte) ([]deviceBatchAllocationRow, err
break
}
if err != nil || len(record) != 1 {
return nil, errors.New(errors.CodeInvalidParam, "设备批量分配CSV必须只有一列设备标识")
return nil, errors.New(errors.CodeInvalidParam, "设备批量CSV必须只有一列设备标识")
}
identifier := strings.TrimSpace(record[0])
if line == 1 && (identifier == "device_identifier" || identifier == "设备标识") {
continue
}
if identifier == "" {
return nil, errors.New(errors.CodeInvalidParam, "设备批量分配CSV存在空设备标识")
return nil, errors.New(errors.CodeInvalidParam, "设备批量CSV存在空设备标识")
}
rows = append(rows, deviceBatchAllocationRow{line: line, identifier: identifier})
if len(rows) > constants.DeviceBatchAllocationMaxRows {
return nil, errors.New(errors.CodeInvalidParam, "设备批量分配CSV最多包含1000行设备")
return nil, errors.New(errors.CodeInvalidParam, "设备批量CSV最多包含1000行设备")
}
}
if len(rows) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "设备批量分配CSV没有有效数据行")
return nil, errors.New(errors.CodeInvalidParam, "设备批量CSV没有有效数据行")
}
return rows, nil
}
@@ -135,7 +141,7 @@ func (h *DeviceImportHandler) executeDeviceBatchAllocation(ctx context.Context,
continue
}
seen[device.ID] = struct{}{}
if deviceAlreadyAtAllocationTarget(device, task.OperationType, *task.TargetID) {
if deviceAlreadyAtAllocationTarget(device, task) {
result.successCount++
continue
}
@@ -179,17 +185,34 @@ func (h *DeviceImportHandler) applyDeviceBatchAllocation(ctx context.Context, ta
for _, item := range response.FailedItems {
failed[item.DeviceID] = item.Reason
}
case constants.DeviceImportOperationRecall:
response, err := h.allocationExecutor.RecallDevices(ctx, &dto.RecallDevicesRequest{DeviceIDs: deviceIDs, Remark: "CSV批量回收任务 " + task.TaskNo}, task.Creator, task.OperatorShopID)
if err != nil {
return nil, err
}
for _, item := range response.FailedItems {
failed[item.DeviceID] = item.Reason
}
default:
return nil, errors.New(errors.CodeInvalidParam, "设备批量分配任务类型不支持")
return nil, errors.New(errors.CodeInvalidParam, "设备CSV批量任务类型不支持")
}
return failed, nil
}
func deviceAlreadyAtAllocationTarget(device *model.Device, operation string, targetID uint) bool {
if operation == constants.DeviceImportOperationAssignShop {
return device.ShopID != nil && *device.ShopID == targetID
func deviceAlreadyAtAllocationTarget(device *model.Device, task *model.DeviceImportTask) bool {
switch task.OperationType {
case constants.DeviceImportOperationAssignShop:
return task.TargetID != nil && device.ShopID != nil && *device.ShopID == *task.TargetID
case constants.DeviceImportOperationAssignSeries:
return task.TargetID != nil && device.SeriesID != nil && *device.SeriesID == *task.TargetID
case constants.DeviceImportOperationRecall:
if task.OperatorShopID == nil {
return device.ShopID == nil
}
return device.ShopID != nil && *device.ShopID == *task.OperatorShopID
default:
return false
}
return operation == constants.DeviceImportOperationAssignSeries && device.SeriesID != nil && *device.SeriesID == targetID
}
func deviceAllocationResultItem(row deviceBatchAllocationRow, reason string) model.ImportResultItem {
@@ -203,9 +226,16 @@ func valueOrZero(value *uint) uint {
return *value
}
func operatorDeviceShopScope(shopID *uint) []uint {
if shopID == nil {
return nil
func (h *DeviceImportHandler) resolveDeviceBatchShopScope(ctx context.Context, task *model.DeviceImportTask) ([]uint, error) {
if task.OperatorShopID == nil {
return nil, nil
}
return []uint{*shopID}
if task.OperationType != constants.DeviceImportOperationRecall {
return []uint{*task.OperatorShopID}, nil
}
shopIDs, err := postgres.NewShopStore(h.db, h.redis).GetSubordinateShopIDs(ctx, *task.OperatorShopID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理设备回收范围失败")
}
return shopIDs, nil
}

View File

@@ -32,6 +32,7 @@ type DeviceImportPayload struct {
type DeviceBatchAllocationExecutor interface {
AllocateDevices(ctx context.Context, req *dto.AllocateDevicesRequest, operatorID uint, operatorShopID *uint) (*dto.AllocateDevicesResponse, error)
BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDeviceSeriesBindngRequest, operatorShopID *uint) (*dto.BatchSetDeviceSeriesBindngResponse, error)
RecallDevices(ctx context.Context, req *dto.RecallDevicesRequest, operatorID uint, operatorShopID *uint) (*dto.RecallDevicesResponse, error)
}
type DeviceImportHandler struct {