Files
assets/service/stock/inventory_count_service.go

1026 lines
33 KiB
Go
Raw Normal View History

2026-03-18 10:18:03 +08:00
// 盘点任务服务
// 职责盘点任务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.com/red-future/common/db/mongo"
"gitea.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
}
}
}