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
|
|||
|
|
}
|