Files
assets/service/stock/inventory_count_service.go
2026-06-10 15:40:17 +08:00

1026 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 盘点任务服务
// 职责盘点任务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_locationIdO(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 IDO(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表获取assetIdskuIDs是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 构建盘点明细复合keyskuId_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
}
}
}