1026 lines
33 KiB
Go
1026 lines
33 KiB
Go
// 盘点任务服务
|
||
// 职责:盘点任务CRUD、完成/取消、导出模板、导入Excel、创建盘点明细
|
||
// 调用链:Create → createInventoryDetails(快照) → ExportTemplate → ImportInventoryCount → adjustStock
|
||
// 紧密耦合:dao.InventoryCount、InventoryCountDetail(明细)、PrivateStock(库存快照)、Capacity(容量更新)
|
||
// 注意:同一时间只允许一个盘点任务,Excel导入使用缓存避免N+1查询
|
||
package service
|
||
|
||
import (
|
||
"assets/consts/public"
|
||
"assets/consts/stock"
|
||
dao "assets/dao/stock"
|
||
dto "assets/model/dto/stock"
|
||
entity "assets/model/entity/stock"
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
"gitea.redpowerfuture.com/red-future/common/db/mongo"
|
||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
"github.com/gogf/gf/v2/os/gtime"
|
||
"github.com/gogf/gf/v2/util/gconv"
|
||
"github.com/xuri/excelize/v2"
|
||
"go.mongodb.org/mongo-driver/v2/bson"
|
||
)
|
||
|
||
type inventoryCount struct{}
|
||
|
||
var InventoryCount = new(inventoryCount)
|
||
|
||
func (s *inventoryCount) Create(ctx context.Context, req *dto.CreateInventoryCountReq) (res *dto.CreateInventoryCountRes, err error) {
|
||
// 检查是否存在未完成的盘点任务(同一时间只允许一个盘点任务)
|
||
has, err := dao.InventoryCount.HasUncompletedTask(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if has {
|
||
return nil, errors.New("存在未完成的盘点任务,请先完成或取消后再创建新任务")
|
||
}
|
||
|
||
// 生成盘点单号
|
||
countNo, err := utils.IncrSequence(ctx, public.StockInventoryNoKeyPrefix, 6, "-")
|
||
if err != nil {
|
||
return nil, fmt.Errorf("生成盘点单号失败: %v", err)
|
||
}
|
||
|
||
// 创建盘点任务主表
|
||
ids, err := dao.InventoryCount.Insert(ctx, req, countNo)
|
||
if err != nil {
|
||
return
|
||
}
|
||
id := ids[0].(bson.ObjectID)
|
||
|
||
// 根据盘点范围查询库存并创建明细记录(记录账面数量快照)
|
||
err = s.createInventoryDetails(ctx, &id, req)
|
||
if err != nil {
|
||
// 回滚:物理删除已插入的主记录,避免残留数据阻塞后续创建
|
||
_, _ = mongo.DB().Delete(ctx, bson.M{"_id": id}, public.InventoryCountCollection)
|
||
return nil, fmt.Errorf("创建盘点明细失败: %v", err)
|
||
}
|
||
|
||
res = &dto.CreateInventoryCountRes{
|
||
Id: &id,
|
||
CountNo: countNo,
|
||
}
|
||
return
|
||
}
|
||
|
||
func (s *inventoryCount) Update(ctx context.Context, req *dto.UpdateInventoryCountReq) error {
|
||
return dao.InventoryCount.Update(ctx, req)
|
||
}
|
||
|
||
func (s *inventoryCount) Delete(ctx context.Context, req *dto.DeleteInventoryCountReq) error {
|
||
// 先删除关联的盘点明细
|
||
if err := dao.InventoryCountDetail.DeleteByCountId(ctx, req.Id); err != nil {
|
||
return fmt.Errorf("删除盘点明细失败: %v", err)
|
||
}
|
||
// 再删除盘点任务本身
|
||
return dao.InventoryCount.Delete(ctx, req)
|
||
}
|
||
|
||
func (s *inventoryCount) GetOne(ctx context.Context, req *dto.GetInventoryCountReq) (res *dto.GetInventoryCountRes, err error) {
|
||
one, err := dao.InventoryCount.GetOne(ctx, req)
|
||
if err != nil {
|
||
return
|
||
}
|
||
err = utils.Struct(one, &res)
|
||
return
|
||
}
|
||
|
||
func (s *inventoryCount) List(ctx context.Context, req *dto.ListInventoryCountReq) (res *dto.ListInventoryCountRes, err error) {
|
||
list, total, err := dao.InventoryCount.List(ctx, req)
|
||
if err != nil {
|
||
return
|
||
}
|
||
res = &dto.ListInventoryCountRes{
|
||
Total: total,
|
||
}
|
||
// 手动构建DTO列表(Entity和DTO字段不完全匹配)
|
||
res.List = make([]dto.InventoryCountListItem, len(list))
|
||
for i, item := range list {
|
||
res.List[i] = dto.InventoryCountListItem{
|
||
Id: item.Id,
|
||
CountNo: item.CountNo,
|
||
Title: item.Title,
|
||
WarehouseIDs: item.WarehouseIDs,
|
||
ZoneIDs: item.ZoneIDs,
|
||
CountType: item.CountType,
|
||
CountTypeText: item.CountType.String(),
|
||
Scope: item.Scope,
|
||
ScopeText: item.Scope.String(),
|
||
Status: item.Status,
|
||
StatusText: item.Status.String(),
|
||
Progress: item.Progress,
|
||
AssigneeID: item.AssigneeID,
|
||
AssigneeName: item.AssigneeName,
|
||
ActualStartTime: item.ActualStartTime,
|
||
ActualEndTime: item.ActualEndTime,
|
||
TotalItems: item.TotalItems,
|
||
CompletedItems: item.CompletedItems,
|
||
DiscrepancyItems: item.DiscrepancyItems,
|
||
CreatedAt: s.timeToGtime(item.CreatedAt),
|
||
UpdatedAt: s.timeToGtime(item.UpdatedAt),
|
||
}
|
||
}
|
||
// 批量填充关联名称
|
||
s.fillListItemNames(ctx, list, res.List)
|
||
return
|
||
}
|
||
|
||
// timeToGtime 将time.Time转换为gtime.Time
|
||
func (s *inventoryCount) timeToGtime(t *time.Time) *gtime.Time {
|
||
if t == nil {
|
||
return nil
|
||
}
|
||
return gtime.New(t)
|
||
}
|
||
|
||
// Complete 完成盘点
|
||
func (s *inventoryCount) Complete(ctx context.Context, req *dto.CompleteInventoryCountReq) (res *dto.CompleteInventoryCountRes, err error) {
|
||
// 查询盘点任务详情,验证任务是否存在
|
||
count, err := dao.InventoryCount.GetOne(ctx, &dto.GetInventoryCountReq{Id: req.Id})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if count.Status != stock.InventoryCountStatusInProgress {
|
||
return nil, fmt.Errorf("盘点任务当前状态为[%s],只有进行中的任务才能完成", count.Status.String())
|
||
}
|
||
|
||
// 查询所有盘点明细记录
|
||
details, err := dao.InventoryCountDetail.ListByCountId(ctx, req.Id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 统计存在差异且未调整的明细数量
|
||
discrepancyCount := 0
|
||
for _, detail := range details {
|
||
if detail.Difference != 0 && !detail.IsAdjusted {
|
||
discrepancyCount++
|
||
}
|
||
}
|
||
|
||
// 有未调整的差异项,不允许完成
|
||
if discrepancyCount > 0 {
|
||
return nil, fmt.Errorf("存在%d个未调整的盘点差异,请先调整后再完成", discrepancyCount)
|
||
}
|
||
|
||
// 更新状态为已完成
|
||
err = dao.InventoryCount.UpdateStatus(ctx, req.Id, stock.InventoryCountStatusCompleted)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &dto.CompleteInventoryCountRes{Id: req.Id}, nil
|
||
}
|
||
|
||
// Cancel 取消盘点
|
||
func (s *inventoryCount) Cancel(ctx context.Context, req *dto.CancelInventoryCountReq) (res *dto.CancelInventoryCountRes, err error) {
|
||
// 查询盘点任务,校验状态
|
||
count, err := dao.InventoryCount.GetOne(ctx, &dto.GetInventoryCountReq{Id: req.Id})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if count.Status != stock.InventoryCountStatusInProgress {
|
||
return nil, fmt.Errorf("盘点任务当前状态为[%s],只有进行中的任务才能取消", count.Status.String())
|
||
}
|
||
|
||
// 回滚已调整的库存(反向$inc)
|
||
details, err := dao.InventoryCountDetail.ListByCountId(ctx, req.Id)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("查询盘点明细失败: %v", err)
|
||
}
|
||
rollbackCount := 0
|
||
for _, detail := range details {
|
||
if detail.IsAdjusted && detail.Difference != 0 {
|
||
// 反向调整:差异取反
|
||
rollbackDetail := detail
|
||
rollbackDetail.Difference = -detail.Difference
|
||
if e := InventoryCountDetail.adjustStock(ctx, &rollbackDetail); e != nil {
|
||
g.Log().Errorf(ctx, "回滚库存失败[SKU=%s]: %v", detail.AssetSkuID.Hex(), e)
|
||
continue
|
||
}
|
||
rollbackCount++
|
||
// 触发库位容量重算
|
||
if !g.IsEmpty(detail.LocationID) {
|
||
if capErr := Capacity.UpdateLocationCapacity(ctx, detail.LocationID); capErr != nil {
|
||
g.Log().Warningf(ctx, "回滚后更新库位容量失败: %v", capErr)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if rollbackCount > 0 {
|
||
g.Log().Infof(ctx, "取消盘点[%s]已回滚%d条库存调整", req.Id.Hex(), rollbackCount)
|
||
}
|
||
|
||
// 更新状态为已取消
|
||
err = dao.InventoryCount.UpdateStatus(ctx, req.Id, stock.InventoryCountStatusCancelled)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &dto.CancelInventoryCountRes{Id: req.Id}, nil
|
||
}
|
||
|
||
// ExportTemplate 导出盘点模板
|
||
func (s *inventoryCount) ExportTemplate(ctx context.Context, req *dto.ExportInventoryCountTemplateReq) (res *dto.ExportInventoryCountTemplateRes, err error) {
|
||
// 1. 查询盘点任务
|
||
count, err := dao.InventoryCount.GetOne(ctx, &dto.GetInventoryCountReq{Id: req.Id})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 2. 根据盘点类型生成Excel模板
|
||
fileData, fileName, err := s.generateExcelTemplate(ctx, count)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("生成Excel模板失败: %v", err)
|
||
}
|
||
|
||
return &dto.ExportInventoryCountTemplateRes{
|
||
FileName: fileName,
|
||
FileData: fileData,
|
||
}, nil
|
||
}
|
||
|
||
// generateExcelTemplate 生成Excel模板
|
||
func (s *inventoryCount) generateExcelTemplate(ctx context.Context, count *entity.InventoryCount) (fileData []byte, fileName string, err error) {
|
||
// 创建Excel文件
|
||
f := excelize.NewFile()
|
||
defer func() {
|
||
if closeErr := f.Close(); closeErr != nil {
|
||
err = closeErr
|
||
}
|
||
}()
|
||
|
||
// 设置工作表名称
|
||
sheetName := "盘点模板"
|
||
f.SetSheetName("Sheet1", sheetName)
|
||
|
||
// 根据盘点类型设置不同表头
|
||
// 明盘:有账面数量列,可以看到历史库存
|
||
// 盲盘:没有账面数量列,只填实盘数量
|
||
var headers []string
|
||
if count.CountType == stock.OpenPrice {
|
||
headers = []string{"资产名称", "SKU名称", "仓库", "库区", "库位", "账面数量", "实盘数量", "备注"}
|
||
} else {
|
||
headers = []string{"资产名称", "SKU名称", "仓库", "库区", "库位", "实盘数量", "备注"}
|
||
}
|
||
for i, header := range headers {
|
||
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||
f.SetCellValue(sheetName, cell, header)
|
||
}
|
||
|
||
// 设置样式
|
||
lastCol, _ := excelize.CoordinatesToCellName(len(headers), 1)
|
||
headerStyle, err := f.NewStyle(&excelize.Style{
|
||
Font: &excelize.Font{
|
||
Bold: true,
|
||
},
|
||
Fill: excelize.Fill{
|
||
Type: "pattern",
|
||
Color: []string{"#E6E6FA"},
|
||
Pattern: 1,
|
||
},
|
||
Alignment: &excelize.Alignment{
|
||
Horizontal: "center",
|
||
Vertical: "center",
|
||
},
|
||
})
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
|
||
f.SetCellStyle(sheetName, "A1", lastCol, headerStyle)
|
||
|
||
// 明盘:填充数据行(含账面数量列)
|
||
// 盲盘:填充数据行(无账面数量列,工人看不到系统库存)
|
||
err = s.fillTemplateData(ctx, f, sheetName, count)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
|
||
// 生成文件名
|
||
fileName = fmt.Sprintf("盘点模板_%s_%s.xlsx", count.CountNo, count.CountType.String())
|
||
|
||
// 保存到缓冲区
|
||
var buf bytes.Buffer
|
||
err = f.Write(&buf)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
|
||
return buf.Bytes(), fileName, nil
|
||
}
|
||
|
||
// fillTemplateData 填充模板数据(明盘包含账面数量,盲盘不包含)
|
||
func (s *inventoryCount) fillTemplateData(ctx context.Context, f *excelize.File, sheetName string, count *entity.InventoryCount) error {
|
||
// 查询盘点明细(包含关联名称信息)
|
||
req := &dto.ListInventoryCountDetailReq{
|
||
CountID: count.Id.Hex(),
|
||
}
|
||
listRes, err := InventoryCountDetail.List(ctx, req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 填充数据
|
||
for i, detail := range listRes.List {
|
||
row := i + 2 // 从第2行开始
|
||
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), detail.AssetName)
|
||
f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), detail.AssetSkuName)
|
||
f.SetCellValue(sheetName, fmt.Sprintf("C%d", row), detail.WarehouseName)
|
||
f.SetCellValue(sheetName, fmt.Sprintf("D%d", row), detail.ZoneName)
|
||
f.SetCellValue(sheetName, fmt.Sprintf("E%d", row), detail.LocationName)
|
||
if count.CountType == stock.OpenPrice {
|
||
// 明盘:F=账面数量, G=实盘数量(空), H=备注
|
||
f.SetCellValue(sheetName, fmt.Sprintf("F%d", row), detail.BookQuantity)
|
||
f.SetCellValue(sheetName, fmt.Sprintf("G%d", row), "")
|
||
f.SetCellValue(sheetName, fmt.Sprintf("H%d", row), "")
|
||
} else {
|
||
// 盲盘:F=实盘数量(空), G=备注
|
||
f.SetCellValue(sheetName, fmt.Sprintf("F%d", row), "")
|
||
f.SetCellValue(sheetName, fmt.Sprintf("G%d", row), "")
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// ImportInventoryCount 上传盘点Excel(一步完成:解析→更新明细→$inc调整库存→自动完成)
|
||
func (s *inventoryCount) ImportInventoryCount(ctx context.Context, req *dto.ImportInventoryCountReq, fileData []byte) (res *dto.ImportInventoryCountRes, err error) {
|
||
// 1. 查询盘点任务详情并校验状态
|
||
count, err := dao.InventoryCount.GetOne(ctx, &dto.GetInventoryCountReq{Id: req.Id})
|
||
if err != nil {
|
||
err = fmt.Errorf("查询盘点任务失败: %v", err)
|
||
return
|
||
}
|
||
if count.Status != stock.InventoryCountStatusInProgress {
|
||
err = fmt.Errorf("盘点任务当前状态为[%s],只有进行中的任务才能导入Excel", count.Status.String())
|
||
return
|
||
}
|
||
|
||
// 2. 解析Excel文件,匹配已创建的detail记录并计算差异
|
||
details, err := s.parseExcelFile(ctx, fileData, count)
|
||
if err != nil {
|
||
err = fmt.Errorf("解析Excel失败: %v", err)
|
||
return
|
||
}
|
||
|
||
if ctx.Err() != nil {
|
||
err = fmt.Errorf("客户端已断开连接: %v", ctx.Err())
|
||
return
|
||
}
|
||
|
||
// 3. 验证所有负差异项调整后库存不为负
|
||
for _, detail := range details {
|
||
if detail.Difference < 0 {
|
||
if e := InventoryCountDetail.validateStockAfterAdjust(ctx, detail); e != nil {
|
||
err = fmt.Errorf("SKU库存校验失败: %v", e)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4. 批量更新明细的实盘数据(不标记完成,先记录实盘数量和差异)
|
||
now := gtime.Now()
|
||
countBy := ""
|
||
if user, e := utils.GetUserInfo(ctx); e == nil {
|
||
countBy = fmt.Sprintf("%v", user.UserName)
|
||
}
|
||
|
||
dataFilters := make([]bson.M, 0, len(details))
|
||
dataUpdates := make([]bson.M, 0, len(details))
|
||
for _, detail := range details {
|
||
var discrepancyType stock.DiscrepancyType
|
||
if detail.Difference > 0 {
|
||
discrepancyType = stock.DiscrepancyTypeOverflow
|
||
} else if detail.Difference < 0 {
|
||
discrepancyType = stock.DiscrepancyTypeShortage
|
||
} else {
|
||
discrepancyType = stock.DiscrepancyTypeNone
|
||
}
|
||
var differenceRate float64
|
||
if detail.BookQuantity > 0 {
|
||
differenceRate = float64(detail.Difference) / float64(detail.BookQuantity) * 100
|
||
}
|
||
|
||
dataFilters = append(dataFilters, bson.M{"_id": detail.Id})
|
||
dataUpdates = append(dataUpdates, bson.M{
|
||
"actualQuantity": detail.ActualQuantity,
|
||
"difference": detail.Difference,
|
||
"differenceRate": differenceRate,
|
||
"discrepancyType": discrepancyType,
|
||
"discrepancyReason": detail.DiscrepancyReason,
|
||
"countAt": now,
|
||
"countBy": countBy,
|
||
})
|
||
}
|
||
|
||
if _, err = mongo.DB().SaveOrUpdate(ctx, dataFilters, dataUpdates, public.InventoryCountDetailCollection); err != nil {
|
||
return nil, fmt.Errorf("批量更新盘点明细失败: %v", err)
|
||
}
|
||
|
||
// 5. 逐条处理:调整库存 + 成功后标记完成
|
||
successCount := 0
|
||
failCount := 0
|
||
completedFilters := make([]bson.M, 0, len(details))
|
||
completedUpdates := make([]bson.M, 0, len(details))
|
||
|
||
for _, detail := range details {
|
||
// 无差异项直接标记完成(无需调整库存)
|
||
if detail.Difference == 0 {
|
||
completedFilters = append(completedFilters, bson.M{"_id": detail.Id})
|
||
completedUpdates = append(completedUpdates, bson.M{
|
||
"status": stock.InventoryDetailStatusCompleted, "isAdjusted": true, "adjustedAt": now, "adjustedBy": countBy,
|
||
})
|
||
successCount++
|
||
continue
|
||
}
|
||
|
||
// 有差异项:先调整库存,成功后才标记完成
|
||
if e := InventoryCountDetail.adjustStock(ctx, detail); e != nil {
|
||
g.Log().Warningf(ctx, "调整库存失败[SKU=%s]: %v", detail.AssetSkuID.Hex(), e)
|
||
failCount++
|
||
continue
|
||
}
|
||
completedFilters = append(completedFilters, bson.M{"_id": detail.Id})
|
||
completedUpdates = append(completedUpdates, bson.M{
|
||
"status": stock.InventoryDetailStatusCompleted, "isAdjusted": true, "adjustedAt": now, "adjustedBy": countBy,
|
||
})
|
||
successCount++
|
||
|
||
// 库存变动后触发库位容量重算
|
||
if !g.IsEmpty(detail.LocationID) {
|
||
if capErr := Capacity.UpdateLocationCapacity(ctx, detail.LocationID); capErr != nil {
|
||
g.Log().Warningf(ctx, "更新库位容量失败: %v", capErr)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 批量标记成功项为已完成
|
||
if len(completedFilters) > 0 {
|
||
if _, e := mongo.DB().SaveOrUpdate(ctx, completedFilters, completedUpdates, public.InventoryCountDetailCollection); e != nil {
|
||
g.Log().Warningf(ctx, "批量标记完成状态失败: %v", e)
|
||
}
|
||
}
|
||
|
||
g.Log().Infof(ctx, "盘点Excel导入完成: 成功=%d, 失败=%d", successCount, failCount)
|
||
|
||
// 6. 更新盘点任务统计
|
||
if e := InventoryCountDetail.updateCountStats(ctx, req.Id); e != nil {
|
||
g.Log().Warningf(ctx, "更新统计失败: %v", e)
|
||
}
|
||
|
||
// 7. 所有明细已调整,自动完成盘点
|
||
if e := InventoryCountDetail.autoCompleteIfNoDifference(ctx, req.Id); e != nil {
|
||
g.Log().Warningf(ctx, "自动完成检查失败: %v", e)
|
||
}
|
||
|
||
// 8. 返回结果
|
||
res = &dto.ImportInventoryCountRes{
|
||
SuccessCount: successCount,
|
||
FailCount: failCount,
|
||
}
|
||
return
|
||
}
|
||
|
||
// excelImportCache Excel导入缓存(预加载基础数据,避免逐行查询数据库)
|
||
// 性能优化:1000行Excel从4000次查询降至4次批量查询
|
||
type excelImportCache struct {
|
||
SkuMap map[string]*skuCacheItem // skuName -> {privateSkuId}
|
||
WarehouseMap map[string]*bson.ObjectID // warehouseName -> id
|
||
ZoneMap map[string]*bson.ObjectID // warehouseId_zoneName -> id
|
||
LocationMap map[string]*bson.ObjectID // zoneId_locationName -> id
|
||
}
|
||
|
||
type skuCacheItem struct {
|
||
ID *bson.ObjectID
|
||
}
|
||
|
||
// buildImportCache 从已有明细收集ID,按需加载基础数据到内存缓存(避免全量加载OOM)
|
||
func (s *inventoryCount) buildImportCache(ctx context.Context, existingDetails []entity.InventoryCountDetail) (cache *excelImportCache, err error) {
|
||
cache = &excelImportCache{
|
||
SkuMap: make(map[string]*skuCacheItem),
|
||
WarehouseMap: make(map[string]*bson.ObjectID),
|
||
ZoneMap: make(map[string]*bson.ObjectID),
|
||
LocationMap: make(map[string]*bson.ObjectID),
|
||
}
|
||
|
||
// 从已有明细中收集去重ID
|
||
skuIdSet := make(map[string]*bson.ObjectID)
|
||
warehouseIdSet := make(map[string]*bson.ObjectID)
|
||
zoneIdSet := make(map[string]*bson.ObjectID)
|
||
locationIdSet := make(map[string]*bson.ObjectID)
|
||
for _, d := range existingDetails {
|
||
if d.AssetSkuID != nil {
|
||
skuIdSet[d.AssetSkuID.Hex()] = d.AssetSkuID
|
||
}
|
||
if d.WarehouseID != nil {
|
||
warehouseIdSet[d.WarehouseID.Hex()] = d.WarehouseID
|
||
}
|
||
if d.ZoneID != nil {
|
||
zoneIdSet[d.ZoneID.Hex()] = d.ZoneID
|
||
}
|
||
if d.LocationID != nil {
|
||
locationIdSet[d.LocationID.Hex()] = d.LocationID
|
||
}
|
||
}
|
||
|
||
// 1. $in查询盘点范围内的PrivateSku
|
||
if len(skuIdSet) > 0 {
|
||
skuIds := make([]*bson.ObjectID, 0, len(skuIdSet))
|
||
for _, id := range skuIdSet {
|
||
skuIds = append(skuIds, id)
|
||
}
|
||
var skus []struct {
|
||
ID bson.ObjectID `bson:"_id"`
|
||
SkuName string `bson:"skuName"`
|
||
}
|
||
_, err = mongo.DB().Find(ctx, bson.M{"_id": bson.M{"$in": skuIds}}, &skus, public.PrivateSkuCollection, nil, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("预加载PrivateSku失败: %v", err)
|
||
}
|
||
for i := range skus {
|
||
cache.SkuMap[skus[i].SkuName] = &skuCacheItem{ID: &skus[i].ID}
|
||
}
|
||
}
|
||
|
||
// 2. $in查询盘点范围内的仓库
|
||
if len(warehouseIdSet) > 0 {
|
||
whIds := make([]*bson.ObjectID, 0, len(warehouseIdSet))
|
||
for _, id := range warehouseIdSet {
|
||
whIds = append(whIds, id)
|
||
}
|
||
var warehouses []struct {
|
||
ID bson.ObjectID `bson:"_id"`
|
||
WarehouseName string `bson:"warehouseName"`
|
||
}
|
||
_, err = mongo.DB().Find(ctx, bson.M{"_id": bson.M{"$in": whIds}}, &warehouses, public.WarehouseCollection, nil, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("预加载仓库失败: %v", err)
|
||
}
|
||
for i := range warehouses {
|
||
cache.WarehouseMap[warehouses[i].WarehouseName] = &warehouses[i].ID
|
||
}
|
||
}
|
||
|
||
// 3. $in查询盘点范围内的库区
|
||
if len(zoneIdSet) > 0 {
|
||
zIds := make([]*bson.ObjectID, 0, len(zoneIdSet))
|
||
for _, id := range zoneIdSet {
|
||
zIds = append(zIds, id)
|
||
}
|
||
var zones []struct {
|
||
ID bson.ObjectID `bson:"_id"`
|
||
ZoneName string `bson:"zoneName"`
|
||
WarehouseID bson.ObjectID `bson:"warehouseId"`
|
||
}
|
||
_, err = mongo.DB().Find(ctx, bson.M{"_id": bson.M{"$in": zIds}}, &zones, public.ZoneCollection, nil, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("预加载库区失败: %v", err)
|
||
}
|
||
for i := range zones {
|
||
key := zones[i].WarehouseID.Hex() + "_" + zones[i].ZoneName
|
||
cache.ZoneMap[key] = &zones[i].ID
|
||
}
|
||
}
|
||
|
||
// 4. $in查询盘点范围内的库位
|
||
if len(locationIdSet) > 0 {
|
||
locIds := make([]*bson.ObjectID, 0, len(locationIdSet))
|
||
for _, id := range locationIdSet {
|
||
locIds = append(locIds, id)
|
||
}
|
||
var locations []struct {
|
||
ID bson.ObjectID `bson:"_id"`
|
||
LocationName string `bson:"locationName"`
|
||
ZoneID bson.ObjectID `bson:"zoneId"`
|
||
}
|
||
_, err = mongo.DB().Find(ctx, bson.M{"_id": bson.M{"$in": locIds}}, &locations, public.LocationCollection, nil, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("预加载库位失败: %v", err)
|
||
}
|
||
for i := range locations {
|
||
key := locations[i].ZoneID.Hex() + "_" + locations[i].LocationName
|
||
cache.LocationMap[key] = &locations[i].ID
|
||
}
|
||
}
|
||
|
||
g.Log().Infof(ctx, "Excel导入缓存预加载完成: SKU=%d, 仓库=%d, 库区=%d, 库位=%d",
|
||
len(cache.SkuMap), len(cache.WarehouseMap), len(cache.ZoneMap), len(cache.LocationMap))
|
||
|
||
return
|
||
}
|
||
|
||
// parseExcelFile 解析Excel文件并更新盘点明细(匹配已有detail记录)
|
||
// 新逻辑:从已创建的detail记录中匹配,更新实盘数量和差异
|
||
func (s *inventoryCount) parseExcelFile(ctx context.Context, fileData []byte, count *entity.InventoryCount) (details []*entity.InventoryCountDetail, err error) {
|
||
// 打开Excel文件
|
||
f, err := excelize.OpenReader(bytes.NewReader(fileData))
|
||
if err != nil {
|
||
err = fmt.Errorf("打开Excel文件失败: %v", err)
|
||
return
|
||
}
|
||
defer f.Close()
|
||
|
||
// 读取第一个工作表的所有行
|
||
sheetName := f.GetSheetName(0)
|
||
rows, err := f.GetRows(sheetName)
|
||
if err != nil {
|
||
err = fmt.Errorf("读取Excel行失败: %v", err)
|
||
return
|
||
}
|
||
|
||
if len(rows) < 2 {
|
||
err = errors.New("Excel文件无数据行")
|
||
return
|
||
}
|
||
|
||
// 预加载已创建的detail记录,用于匹配Excel中的数据
|
||
existingDetails, err := dao.InventoryCountDetail.ListByCountId(ctx, count.Id)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("查询盘点明细失败: %v", err)
|
||
}
|
||
if len(existingDetails) == 0 {
|
||
return nil, errors.New("盘点任务无明细记录,请检查任务创建流程")
|
||
}
|
||
|
||
// 预加载SKU/仓库/库区/库位映射缓存(仅加载盘点范围内数据,避免OOM)
|
||
cache, err := s.buildImportCache(ctx, existingDetails)
|
||
if err != nil {
|
||
return
|
||
}
|
||
|
||
// 构建detail Map缓存,key=skuId_warehouseId_zoneId_locationId,O(1)查找替代O(n²)嵌套循环
|
||
detailMap := make(map[string]*entity.InventoryCountDetail, len(existingDetails))
|
||
for j := range existingDetails {
|
||
d := &existingDetails[j]
|
||
key := buildDetailKey(d.AssetSkuID, d.WarehouseID, d.ZoneID, d.LocationID)
|
||
detailMap[key] = d
|
||
}
|
||
|
||
// 根据盘点类型确定列索引
|
||
// 明盘:资产名称(0) SKU名称(1) 仓库(2) 库区(3) 库位(4) 账面数量(5) 实盘数量(6) 备注(7)
|
||
// 盲盘:资产名称(0) SKU名称(1) 仓库(2) 库区(3) 库位(4) 实盘数量(5) 备注(6)
|
||
actualQtyCol := 6 // 明盘:实盘数量在第7列
|
||
remarkCol := 7 // 明盘:备注在第8列
|
||
minCols := 7 // 明盘:至少7列
|
||
if count.CountType == stock.HiddenPrice {
|
||
actualQtyCol = 5 // 盲盘:实盘数量在第6列
|
||
remarkCol = 6 // 盲盘:备注在第7列
|
||
minCols = 6 // 盲盘:至少6列
|
||
}
|
||
|
||
// 逐行解析Excel数据(从第2行开始,第1行是表头)
|
||
for i := 1; i < len(rows); i++ {
|
||
row := rows[i]
|
||
if len(row) < minCols {
|
||
continue
|
||
}
|
||
|
||
// 读取Excel列:SKU名称、仓库、库区、库位
|
||
assetSkuName := row[1]
|
||
warehouseName := row[2]
|
||
zoneName := ""
|
||
if len(row) > 3 {
|
||
zoneName = row[3]
|
||
}
|
||
locationName := ""
|
||
if len(row) > 4 {
|
||
locationName = row[4]
|
||
}
|
||
|
||
// 从预加载缓存中查询SKU ID(O(1)查找,避免数据库查询)
|
||
skuItem, ok := cache.SkuMap[assetSkuName]
|
||
if !ok {
|
||
err = fmt.Errorf("第%d行:未找到SKU[%s]", i+1, assetSkuName)
|
||
return
|
||
}
|
||
assetSkuID := skuItem.ID
|
||
|
||
// 从缓存查询仓库ID
|
||
warehouseID, ok := cache.WarehouseMap[warehouseName]
|
||
if !ok {
|
||
err = fmt.Errorf("第%d行:未找到仓库[%s]", i+1, warehouseName)
|
||
return
|
||
}
|
||
|
||
// 从缓存查询库区ID(可选字段)
|
||
var zoneID *bson.ObjectID
|
||
if zoneName != "" {
|
||
zoneKey := warehouseID.Hex() + "_" + zoneName
|
||
zoneID, ok = cache.ZoneMap[zoneKey]
|
||
if !ok {
|
||
err = fmt.Errorf("第%d行:未找到库区[%s]", i+1, zoneName)
|
||
return
|
||
}
|
||
}
|
||
|
||
// 从缓存查询库位ID(可选字段)
|
||
var locationID *bson.ObjectID
|
||
if locationName != "" && zoneID != nil {
|
||
locationKey := zoneID.Hex() + "_" + locationName
|
||
locationID, ok = cache.LocationMap[locationKey]
|
||
if !ok {
|
||
err = fmt.Errorf("第%d行:未找到库位[%s]", i+1, locationName)
|
||
return
|
||
}
|
||
}
|
||
|
||
// 从detailMap中O(1)查找匹配项(替代O(n²)嵌套循环)
|
||
detailKey := buildDetailKey(assetSkuID, warehouseID, zoneID, locationID)
|
||
matchedDetail := detailMap[detailKey]
|
||
|
||
if matchedDetail == nil {
|
||
err = fmt.Errorf("第%d行:未找到对应的盘点明细记录[SKU=%s,仓库=%s]", i+1, assetSkuName, warehouseName)
|
||
return
|
||
}
|
||
|
||
// 读取并验证实盘数量
|
||
actualQty := gconv.Int(row[actualQtyCol])
|
||
if actualQty < 0 {
|
||
err = fmt.Errorf("第%d行:实盘数量不能为负数", i+1)
|
||
return
|
||
}
|
||
|
||
// 使用创建任务时记录的账面数量计算差异(而非Excel中的账面数量)
|
||
difference := actualQty - matchedDetail.BookQuantity
|
||
|
||
// 读取备注
|
||
remark := ""
|
||
if len(row) > remarkCol {
|
||
remark = row[remarkCol]
|
||
}
|
||
// 明盘+负差异时必须有原因
|
||
if count.CountType == stock.OpenPrice && difference < 0 && remark == "" {
|
||
err = fmt.Errorf("第%d行:明盘且实物少于账面时,必须填写备注", i+1)
|
||
return
|
||
}
|
||
|
||
// 更新匹配的detail记录(实盘数量、差异、差异原因)
|
||
matchedDetail.ActualQuantity = actualQty
|
||
matchedDetail.Difference = difference
|
||
matchedDetail.DiscrepancyReason = remark
|
||
details = append(details, matchedDetail)
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
// createInventoryDetails 创建盘点明细,根据盘点范围查询private_stock库存并批量生成detail记录,锁定账面数量快照
|
||
func (s *inventoryCount) createInventoryDetails(ctx context.Context, countId *bson.ObjectID, req *dto.CreateInventoryCountReq) error {
|
||
// 根据盘点范围查询private_stock库存
|
||
stocks, err := s.queryStocksByScope(ctx, req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if len(stocks) == 0 {
|
||
return errors.New("查询范围内无库存数据,请检查盘点范围设置")
|
||
}
|
||
|
||
// 收集所有privateSkuID,准备批量查询
|
||
skuIDs := make([]*bson.ObjectID, 0, len(stocks))
|
||
for _, stk := range stocks {
|
||
skuIDs = append(skuIDs, stk.PrivateSkuID)
|
||
}
|
||
|
||
// 批量查询private_sku表获取assetId(skuIDs是privateSkuId,查PrivateSkuCollection)
|
||
var skuList []struct {
|
||
ID *bson.ObjectID `bson:"_id"`
|
||
AssetID *bson.ObjectID `bson:"assetId"`
|
||
}
|
||
filter := bson.M{"_id": bson.M{"$in": skuIDs}}
|
||
_, err = mongo.DB().Find(ctx, filter, &skuList, public.PrivateSkuCollection, nil, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("批量查询PrivateSku失败: %v", err)
|
||
}
|
||
|
||
// 构建Map缓存,用于O(1)查找privateSkuID对应的assetID
|
||
skuMap := make(map[string]*bson.ObjectID, len(skuList))
|
||
for i := range skuList {
|
||
skuMap[skuList[i].ID.Hex()] = skuList[i].AssetID
|
||
}
|
||
|
||
// 遍历库存快照,构建盘点明细记录
|
||
details := make([]*entity.InventoryCountDetail, 0, len(stocks))
|
||
for _, stk := range stocks {
|
||
assetID, ok := skuMap[stk.PrivateSkuID.Hex()]
|
||
if !ok {
|
||
g.Log().Warningf(ctx, "未找到私域SKU[%s]的assetId,跳过", stk.PrivateSkuID.Hex())
|
||
continue
|
||
}
|
||
|
||
detail := &entity.InventoryCountDetail{
|
||
CountID: countId,
|
||
AssetID: assetID,
|
||
AssetSkuID: stk.PrivateSkuID,
|
||
WarehouseID: stk.WarehouseID,
|
||
ZoneID: stk.ZoneID,
|
||
LocationID: stk.LocationID,
|
||
BookQuantity: stk.AvailableQty,
|
||
ActualQuantity: 0,
|
||
Difference: 0,
|
||
Status: stock.InventoryDetailStatusPending,
|
||
}
|
||
details = append(details, detail)
|
||
}
|
||
|
||
// 批量插入盘点明细记录
|
||
_, err = dao.InventoryCountDetail.InsertBatch(ctx, details)
|
||
if err != nil {
|
||
return fmt.Errorf("批量插入盘点明细失败: %v", err)
|
||
}
|
||
|
||
g.Log().Infof(ctx, "盘点任务[%s]创建明细成功,共%d条", countId.Hex(), len(details))
|
||
return nil
|
||
}
|
||
|
||
// queryStocksByScope 根据仓库/库区/库位/SKU范围过滤条件查询private_stock表,返回库存快照列表
|
||
func (s *inventoryCount) queryStocksByScope(ctx context.Context, req *dto.CreateInventoryCountReq) (stocks []*stockSnapshot, err error) {
|
||
filter := bson.M{}
|
||
|
||
// 必填:仓库ID列表($in批量查询)
|
||
warehouseIDs, err := hexSliceToObjectIDs(req.WarehouseIDs)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("仓库ID格式错误: %v", err)
|
||
}
|
||
if len(warehouseIDs) > 0 {
|
||
filter["warehouseId"] = bson.M{"$in": warehouseIDs}
|
||
}
|
||
|
||
// 可选:库区ID列表
|
||
if len(req.ZoneIDs) > 0 {
|
||
zoneIDs, e := hexSliceToObjectIDs(req.ZoneIDs)
|
||
if e != nil {
|
||
return nil, fmt.Errorf("库区ID格式错误: %v", e)
|
||
}
|
||
if len(zoneIDs) > 0 {
|
||
filter["zoneId"] = bson.M{"$in": zoneIDs}
|
||
}
|
||
}
|
||
|
||
// 可选:库位ID列表
|
||
if len(req.LocationIDs) > 0 {
|
||
locationIDs, e := hexSliceToObjectIDs(req.LocationIDs)
|
||
if e != nil {
|
||
return nil, fmt.Errorf("库位ID格式错误: %v", e)
|
||
}
|
||
if len(locationIDs) > 0 {
|
||
filter["locationId"] = bson.M{"$in": locationIDs}
|
||
}
|
||
}
|
||
|
||
// 可选:资产SKU ID列表
|
||
if len(req.AssetSkuIDs) > 0 {
|
||
assetSkuIDs, e := hexSliceToObjectIDs(req.AssetSkuIDs)
|
||
if e != nil {
|
||
return nil, fmt.Errorf("资产SKU ID格式错误: %v", e)
|
||
}
|
||
if len(assetSkuIDs) > 0 {
|
||
filter["privateSkuId"] = bson.M{"$in": assetSkuIDs}
|
||
}
|
||
}
|
||
|
||
// 查询private_stock表,获取库存快照
|
||
_, err = mongo.DB().Find(ctx, filter, &stocks, public.PrivateStockCollection, nil, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("查询库存失败: %v", err)
|
||
}
|
||
|
||
return stocks, nil
|
||
}
|
||
|
||
// hexSliceToObjectIDs 将hex字符串切片转换为ObjectID切片(跳过空值)
|
||
func hexSliceToObjectIDs(hexIDs []string) ([]bson.ObjectID, error) {
|
||
result := make([]bson.ObjectID, 0, len(hexIDs))
|
||
for _, hex := range hexIDs {
|
||
if g.IsEmpty(hex) {
|
||
continue
|
||
}
|
||
id, err := bson.ObjectIDFromHex(hex)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("值:%s, %v", hex, err)
|
||
}
|
||
result = append(result, id)
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// buildDetailKey 构建盘点明细复合key(skuId_warehouseId_zoneId_locationId),用于O(1) Map查找
|
||
func buildDetailKey(skuID, warehouseID, zoneID, locationID *bson.ObjectID) string {
|
||
sk, wk, zk, lk := "", "", "", ""
|
||
if skuID != nil {
|
||
sk = skuID.Hex()
|
||
}
|
||
if warehouseID != nil {
|
||
wk = warehouseID.Hex()
|
||
}
|
||
if zoneID != nil {
|
||
zk = zoneID.Hex()
|
||
}
|
||
if locationID != nil {
|
||
lk = locationID.Hex()
|
||
}
|
||
return sk + "_" + wk + "_" + zk + "_" + lk
|
||
}
|
||
|
||
// stockSnapshot 库存快照结构体,映射private_stock表字段,用于盘点明细的账面数量记录
|
||
type stockSnapshot struct {
|
||
PrivateSkuID *bson.ObjectID `bson:"privateSkuId"`
|
||
WarehouseID *bson.ObjectID `bson:"warehouseId"`
|
||
ZoneID *bson.ObjectID `bson:"zoneId"`
|
||
LocationID *bson.ObjectID `bson:"locationId"`
|
||
AvailableQty int `bson:"availableQty"`
|
||
}
|
||
|
||
// fillListItemNames 批量填充列表项的关联名称(避免N+1查询)
|
||
func (s *inventoryCount) fillListItemNames(ctx context.Context, entities []entity.InventoryCount, items []dto.InventoryCountListItem) {
|
||
if len(entities) == 0 {
|
||
return
|
||
}
|
||
|
||
// 1. 收集所有需要查询的ID(去重)
|
||
warehouseIdSet := make(map[string]*bson.ObjectID)
|
||
zoneIdSet := make(map[string]*bson.ObjectID)
|
||
for _, e := range entities {
|
||
for _, id := range e.WarehouseIDs {
|
||
if id != nil {
|
||
warehouseIdSet[id.Hex()] = id
|
||
}
|
||
}
|
||
for _, id := range e.ZoneIDs {
|
||
if id != nil {
|
||
zoneIdSet[id.Hex()] = id
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. 批量查询仓库名称
|
||
warehouseNameMap := make(map[string]string)
|
||
if len(warehouseIdSet) > 0 {
|
||
ids := make([]*bson.ObjectID, 0, len(warehouseIdSet))
|
||
for _, id := range warehouseIdSet {
|
||
ids = append(ids, id)
|
||
}
|
||
var warehouses []struct {
|
||
Id *bson.ObjectID `bson:"_id"`
|
||
Name string `bson:"warehouseName"`
|
||
}
|
||
if _, e := mongo.DB().Find(ctx, bson.M{"_id": bson.M{"$in": ids}}, &warehouses, public.WarehouseCollection, nil, nil); e == nil {
|
||
for _, w := range warehouses {
|
||
warehouseNameMap[w.Id.Hex()] = w.Name
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. 批量查询库区名称
|
||
zoneNameMap := make(map[string]string)
|
||
if len(zoneIdSet) > 0 {
|
||
ids := make([]*bson.ObjectID, 0, len(zoneIdSet))
|
||
for _, id := range zoneIdSet {
|
||
ids = append(ids, id)
|
||
}
|
||
var zones []struct {
|
||
Id *bson.ObjectID `bson:"_id"`
|
||
Name string `bson:"zoneName"`
|
||
}
|
||
if _, e := mongo.DB().Find(ctx, bson.M{"_id": bson.M{"$in": ids}}, &zones, public.ZoneCollection, nil, nil); e == nil {
|
||
for _, z := range zones {
|
||
zoneNameMap[z.Id.Hex()] = z.Name
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4. 填充名称到列表项
|
||
for i := range items {
|
||
// 填充仓库名称列表
|
||
if len(entities[i].WarehouseIDs) > 0 {
|
||
names := make([]string, 0, len(entities[i].WarehouseIDs))
|
||
for _, id := range entities[i].WarehouseIDs {
|
||
if id != nil {
|
||
if name, ok := warehouseNameMap[id.Hex()]; ok {
|
||
names = append(names, name)
|
||
}
|
||
}
|
||
}
|
||
items[i].WarehouseNames = names
|
||
}
|
||
// 填充库区名称列表
|
||
if len(entities[i].ZoneIDs) > 0 {
|
||
names := make([]string, 0, len(entities[i].ZoneIDs))
|
||
for _, id := range entities[i].ZoneIDs {
|
||
if id != nil {
|
||
if name, ok := zoneNameMap[id.Hex()]; ok {
|
||
names = append(names, name)
|
||
}
|
||
}
|
||
}
|
||
items[i].ZoneNames = names
|
||
}
|
||
}
|
||
}
|