400 lines
12 KiB
Go
400 lines
12 KiB
Go
// 盘点明细服务
|
||
// 职责:盘点明细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
|
||
}
|