Files
assets/service/stock/inventory_count_detail_service.go
2026-03-18 10:18:03 +08:00

400 lines
12 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、库存调整(adjustStock使用$inc原子操作)、统计更新、相似商品查询
// 调用链InventoryCount.Import → adjustStock → validateStockAfterAdjust → autoCompleteIfNoDifference
// 紧密耦合dao.InventoryCountDetail、PrivateStock(调整库存)、InventoryCount(更新统计)
// 注意AssetSkuID字段实际存储的是privateSkuId列表查询使用批量填充避免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"
"context"
"errors"
"fmt"
"gitea.com/red-future/common/db/mongo"
"gitea.com/red-future/common/utils"
"github.com/gogf/gf/v2/frame/g"
"go.mongodb.org/mongo-driver/v2/bson"
)
type inventoryCountDetail struct{}
var InventoryCountDetail = new(inventoryCountDetail)
func (s *inventoryCountDetail) Create(ctx context.Context, req *dto.CreateInventoryCountDetailReq) (res *dto.CreateInventoryCountDetailRes, err error) {
ids, err := dao.InventoryCountDetail.Insert(ctx, req)
if err != nil {
return
}
id := ids[0].(bson.ObjectID)
res = &dto.CreateInventoryCountDetailRes{
Id: &id,
}
return
}
func (s *inventoryCountDetail) Update(ctx context.Context, req *dto.UpdateInventoryCountDetailReq) error {
return dao.InventoryCountDetail.Update(ctx, req)
}
func (s *inventoryCountDetail) Delete(ctx context.Context, req *dto.DeleteInventoryCountDetailReq) error {
return dao.InventoryCountDetail.DeleteFake(ctx, req)
}
func (s *inventoryCountDetail) GetOne(ctx context.Context, req *dto.GetInventoryCountDetailReq) (res *dto.GetInventoryCountDetailRes, err error) {
one, err := dao.InventoryCountDetail.GetOne(ctx, req)
if err != nil {
return
}
err = utils.Struct(one, &res)
return
}
func (s *inventoryCountDetail) List(ctx context.Context, req *dto.ListInventoryCountDetailReq) (res *dto.ListInventoryCountDetailRes, err error) {
list, total, err := dao.InventoryCountDetail.List(ctx, req)
if err != nil {
return
}
res = &dto.ListInventoryCountDetailRes{
Total: total,
}
err = utils.Struct(list, &res.List)
if err != nil {
return
}
// 批量查询关联名称避免N+1查询
s.fillListItemNames(ctx, list, res.List)
return
}
// fillListItemNames 批量填充列表项的关联名称
func (s *inventoryCountDetail) fillListItemNames(ctx context.Context, details []entity.InventoryCountDetail, items []dto.InventoryCountDetailListItem) {
if len(details) == 0 {
return
}
// 1. 收集所有需要查询的ID去重
assetIdSet := make(map[string]*bson.ObjectID)
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 details {
if d.AssetID != nil {
assetIdSet[d.AssetID.Hex()] = d.AssetID
}
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
}
}
// 2. 批量查询asset名称
assetNameMap := make(map[string]string)
if len(assetIdSet) > 0 {
ids := make([]*bson.ObjectID, 0, len(assetIdSet))
for _, id := range assetIdSet {
ids = append(ids, id)
}
var assets []struct {
Id *bson.ObjectID `bson:"_id"`
Name string `bson:"assetName"`
}
if _, e := mongo.DB().Find(ctx, bson.M{"_id": bson.M{"$in": ids}}, &assets, public.AssetCollection, nil, nil); e == nil {
for _, a := range assets {
assetNameMap[a.Id.Hex()] = a.Name
}
}
}
// 3. 批量查询private_sku名称AssetSkuID实际存的是privateSkuId
skuNameMap := make(map[string]string)
if len(skuIdSet) > 0 {
ids := make([]*bson.ObjectID, 0, len(skuIdSet))
for _, id := range skuIdSet {
ids = append(ids, id)
}
var skus []struct {
Id *bson.ObjectID `bson:"_id"`
Name string `bson:"skuName"`
}
if _, e := mongo.DB().Find(ctx, bson.M{"_id": bson.M{"$in": ids}}, &skus, public.PrivateSkuCollection, nil, nil); e == nil {
for _, s := range skus {
skuNameMap[s.Id.Hex()] = s.Name
}
}
}
// 4. 批量查询warehouse名称
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
}
}
}
// 5. 批量查询zone名称
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
}
}
}
// 6. 批量查询location名称
locationNameMap := make(map[string]string)
if len(locationIdSet) > 0 {
ids := make([]*bson.ObjectID, 0, len(locationIdSet))
for _, id := range locationIdSet {
ids = append(ids, id)
}
var locations []struct {
Id *bson.ObjectID `bson:"_id"`
Name string `bson:"locationName"`
}
if _, e := mongo.DB().Find(ctx, bson.M{"_id": bson.M{"$in": ids}}, &locations, public.LocationCollection, nil, nil); e == nil {
for _, l := range locations {
locationNameMap[l.Id.Hex()] = l.Name
}
}
}
// 7. 填充名称到列表项
for i := range items {
if details[i].AssetID != nil {
items[i].AssetName = assetNameMap[details[i].AssetID.Hex()]
}
if details[i].AssetSkuID != nil {
items[i].AssetSkuName = skuNameMap[details[i].AssetSkuID.Hex()]
}
if details[i].WarehouseID != nil {
items[i].WarehouseName = warehouseNameMap[details[i].WarehouseID.Hex()]
}
if details[i].ZoneID != nil {
items[i].ZoneName = zoneNameMap[details[i].ZoneID.Hex()]
}
if details[i].LocationID != nil {
items[i].LocationName = locationNameMap[details[i].LocationID.Hex()]
}
// 填充差异类型文本
if details[i].DiscrepancyType != 0 {
items[i].DiscrepancyTypeText = details[i].DiscrepancyType.String()
}
}
}
// adjustStock 原子操作调整库存更新private_stock表使用$inc原子加减
func (s *inventoryCountDetail) adjustStock(ctx context.Context, detail *entity.InventoryCountDetail) (err error) {
// 使用MongoDB的$inc原子操作无锁并发安全
// 注意盘点明细的AssetSkuID字段实际存储的是privateSkuId
filter := bson.M{
"privateSkuId": detail.AssetSkuID,
"warehouseId": detail.WarehouseID,
}
if !g.IsEmpty(detail.ZoneID) {
filter["zoneId"] = detail.ZoneID
}
if !g.IsEmpty(detail.LocationID) {
filter["locationId"] = detail.LocationID
}
update := bson.M{
"$inc": bson.M{
"availableQty": detail.Difference,
},
}
_, err = mongo.DB().Update(ctx, filter, update, public.PrivateStockCollection)
return
}
// updateCountStats 更新盘点任务统计信息
func (s *inventoryCountDetail) updateCountStats(ctx context.Context, countId *bson.ObjectID) (err error) {
details, err := dao.InventoryCountDetail.ListByCountId(ctx, countId)
if err != nil {
return
}
totalItems := len(details)
completedItems := 0
discrepancyItems := 0
for _, detail := range details {
if detail.IsAdjusted {
completedItems++
}
if detail.Difference != 0 {
discrepancyItems++
}
}
err = dao.InventoryCount.UpdateStats(ctx, countId, totalItems, completedItems, discrepancyItems)
return
}
// validateStockAfterAdjust 验证调整后库存不能为负数查询private_stock表
func (s *inventoryCountDetail) validateStockAfterAdjust(ctx context.Context, detail *entity.InventoryCountDetail) (err error) {
// 查询当前库存注意盘点明细的AssetSkuID字段实际存储的是privateSkuId
filter := bson.M{
"privateSkuId": detail.AssetSkuID,
"warehouseId": detail.WarehouseID,
}
if !g.IsEmpty(detail.ZoneID) {
filter["zoneId"] = detail.ZoneID
}
if !g.IsEmpty(detail.LocationID) {
filter["locationId"] = detail.LocationID
}
// 聚合同SKU同位置的所有批次总可用数量
var stocks []struct {
AvailableQty int `bson:"availableQty" json:"availableQty"`
}
_, err = mongo.DB().Find(ctx, filter, &stocks, public.PrivateStockCollection, nil, nil)
if err != nil {
if detail.Difference < 0 {
err = errors.New("当前库存不存在,无法执行减少操作")
return
}
return
}
totalQty := 0
for _, s := range stocks {
totalQty += s.AvailableQty
}
afterQty := totalQty + detail.Difference
if afterQty < 0 {
err = fmt.Errorf("调整后库存为负数(当前库存%d差异%d结果%d不允许调整", totalQty, detail.Difference, afterQty)
}
return
}
// autoCompleteIfNoDifference 所有明细都已调整(含无差异自动调整)时,自动完成盘点
func (s *inventoryCountDetail) autoCompleteIfNoDifference(ctx context.Context, countId *bson.ObjectID) (err error) {
details, err := dao.InventoryCountDetail.ListByCountId(ctx, countId)
if err != nil {
return
}
for _, detail := range details {
// 只要有一条未调整(含未盘点),就不自动完成
if !detail.IsAdjusted {
return
}
}
err = dao.InventoryCount.UpdateStatus(ctx, countId, stock.InventoryCountStatusCompleted)
return
}
// SearchSimilarAssets 查询相似商品(单字模糊匹配)
// 用于库存不存在时提示用户可能的相似商品
// 流程先查private_sku模糊匹配skuName → $in查private_stock获取库存 → 关联填充名称
func (s *inventoryCountDetail) SearchSimilarAssets(ctx context.Context, req *dto.SearchSimilarAssetsReq) (res *dto.SearchSimilarAssetsRes, err error) {
// 1. 单字分词:将关键词拆分为单个字符
keywords := []string{}
for _, char := range req.Keyword {
keywords = append(keywords, string(char))
}
// 2. 查private_sku表模糊匹配skuName
orConditions := []bson.M{}
for _, keyword := range keywords {
orConditions = append(orConditions, bson.M{"skuName": bson.M{"$regex": keyword, "$options": "i"}})
}
var matchedSkus []struct {
ID *bson.ObjectID `bson:"_id"`
SkuName string `bson:"skuName"`
}
_, err = mongo.DB().Find(ctx, bson.M{"$or": orConditions}, &matchedSkus, public.PrivateSkuCollection, nil, nil)
if err != nil {
return
}
if len(matchedSkus) == 0 {
res = &dto.SearchSimilarAssetsRes{List: []dto.SimilarAssetItem{}}
return
}
// 3. 构建skuId列表和名称Map
skuIds := make([]*bson.ObjectID, 0, len(matchedSkus))
skuNameMap := make(map[string]string, len(matchedSkus))
for _, sku := range matchedSkus {
skuIds = append(skuIds, sku.ID)
skuNameMap[sku.ID.Hex()] = sku.SkuName
}
// 4. $in查private_stock表获取库存
stockFilter := bson.M{
"privateSkuId": bson.M{"$in": skuIds},
"availableQty": bson.M{"$gt": 0},
}
if !g.IsEmpty(req.WarehouseID) {
stockFilter["warehouseId"] = req.WarehouseID
}
var stocks []struct {
PrivateSkuID *bson.ObjectID `bson:"privateSkuId"`
AvailableQty int `bson:"availableQty"`
WarehouseID *bson.ObjectID `bson:"warehouseId"`
WarehouseName string `bson:"warehouseName"`
}
_, err = mongo.DB().Find(ctx, stockFilter, &stocks, public.PrivateStockCollection, nil, nil)
if err != nil {
return
}
// 5. 转换为响应结构
var list []dto.SimilarAssetItem
for _, s := range stocks {
skuName := ""
if s.PrivateSkuID != nil {
skuName = skuNameMap[s.PrivateSkuID.Hex()]
}
list = append(list, dto.SimilarAssetItem{
AssetSkuID: s.PrivateSkuID,
AssetSkuName: skuName,
AvailableQty: s.AvailableQty,
WarehouseID: s.WarehouseID,
WarehouseName: s.WarehouseName,
})
}
res = &dto.SearchSimilarAssetsRes{List: list}
return
}