Dockerfile
This commit is contained in:
289
service/stock/capacity_service.go
Normal file
289
service/stock/capacity_service.go
Normal file
@@ -0,0 +1,289 @@
|
||||
// 库位容量管理服务
|
||||
// 职责:库位/库区/仓库三级容量计算与同步,支持整入整出换算
|
||||
// 调用链:PrivateStock.Create/Update/Delete → UpdateLocationCapacity → SyncCapacityToZone → SyncCapacityToWarehouse
|
||||
// 紧密耦合:dao.Location(更新容量)、dao.Zone(汇总)、dao.Warehouse(汇总)、dao.UnitConversion(单位换算)
|
||||
// 注意:使用Redis分布式锁防止并发重算覆盖,锁key格式 lock:location:{id}:capacity
|
||||
package service
|
||||
|
||||
import (
|
||||
"assets/consts/public"
|
||||
"assets/consts/stock"
|
||||
dao "assets/dao/stock"
|
||||
dto "assets/model/dto/stock"
|
||||
entityAsset "assets/model/entity/asset"
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"gitea.com/red-future/common/db/mongo"
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"gitea.com/red-future/common/redis"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
var Capacity = new(capacity)
|
||||
|
||||
type capacity struct{}
|
||||
|
||||
// UpdateLocationCapacity 更新库位容量(入口方法,带Redis分布式锁)
|
||||
func (s *capacity) UpdateLocationCapacity(ctx context.Context, locationId *bson.ObjectID) (err error) {
|
||||
// Redis分布式锁(防止并发入库/出库同一库位时重算覆盖)
|
||||
lockKey := fmt.Sprintf("lock:location:%s:capacity", locationId.Hex())
|
||||
expireSeconds := int64(30)
|
||||
|
||||
var zoneId *bson.ObjectID
|
||||
var capacityUnitType stock.CapacityUnitType
|
||||
|
||||
success, err := redis.Lock(ctx, lockKey, expireSeconds, func(ctx context.Context) error {
|
||||
// 1. 查询库位信息
|
||||
location, err := dao.Location.GetOne(ctx, &dto.GetLocationReq{Id: locationId})
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "查询库位失败: %v", err)
|
||||
return err
|
||||
}
|
||||
zoneId = location.ZoneId
|
||||
capacityUnitType = location.CapacityUnitType
|
||||
|
||||
// 2. 查询库位下所有库存记录
|
||||
privateStocks, _, err := dao.PrivateStock.List(ctx, &dto.ListPrivateStockReq{
|
||||
LocationId: locationId,
|
||||
})
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "查询库位库存失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. 批量查询PrivateSku(避免N+1查询问题)
|
||||
skuIds := make([]*bson.ObjectID, 0, len(privateStocks))
|
||||
for _, ps := range privateStocks {
|
||||
if ps.PrivateSkuID != nil && ps.AvailableQty > 0 {
|
||||
skuIds = append(skuIds, ps.PrivateSkuID)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量查询PrivateSku并构建Map缓存
|
||||
skuMap := make(map[string]*entityAsset.PrivateSku)
|
||||
if len(skuIds) > 0 {
|
||||
var skuList []*entityAsset.PrivateSku
|
||||
filter := bson.M{"_id": bson.M{"$in": skuIds}}
|
||||
_, err = mongo.DB().Find(ctx, filter, &skuList, public.PrivateSkuCollection, nil, nil)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "批量查询PrivateSku失败: %v", err)
|
||||
return err
|
||||
}
|
||||
// 构建Map缓存
|
||||
for i := range skuList {
|
||||
skuMap[skuList[i].Id.Hex()] = skuList[i]
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 整入整出计算:先按SKU聚合同库位的总数量(同SKU可合箱),再按库位单位换算
|
||||
// 聚合同一SKU的总数量,避免逐条取整导致容量虚高
|
||||
// 例:2批次各1瓶,逐条取整=2箱(错误),聚合后取整=ceil(2/20)=1箱(正确)
|
||||
skuQtyMap := make(map[string]int) // key: privateSkuId.Hex(), value: 总可用数量
|
||||
for _, ps := range privateStocks {
|
||||
if ps.PrivateSkuID == nil || ps.AvailableQty <= 0 {
|
||||
continue
|
||||
}
|
||||
skuQtyMap[ps.PrivateSkuID.Hex()] += ps.AvailableQty
|
||||
}
|
||||
|
||||
totalCapacity := 0
|
||||
for skuIdHex, totalQty := range skuQtyMap {
|
||||
// 从Map缓存中获取PrivateSku
|
||||
privateSku, exists := skuMap[skuIdHex]
|
||||
if !exists || privateSku == nil {
|
||||
g.Log().Warningf(ctx, "PrivateSku不存在,跳过: %s", skuIdHex)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查location和privateSku的Capacity是否为nil
|
||||
if location.Capacity == nil || privateSku.Capacity.CapacityUnit == "" {
|
||||
g.Log().Warningf(ctx, "库位或SKU容量信息不完整,跳过")
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果库存单位与库位单位相同,直接累加
|
||||
if privateSku.Capacity.CapacityUnit == location.Capacity.CapacityUnit {
|
||||
totalCapacity += totalQty
|
||||
continue
|
||||
}
|
||||
|
||||
// 不同单位需要换算
|
||||
conversion, err := dao.UnitConversion.GetByUnits(ctx,
|
||||
location.CapacityUnitType,
|
||||
privateSku.Capacity.CapacityUnit,
|
||||
location.Capacity.CapacityUnit,
|
||||
)
|
||||
if err != nil {
|
||||
err = gerror.Newf("未找到单位换算规则 %s→%s,请在系统中添加该换算规则",
|
||||
privateSku.Capacity.CapacityUnit, location.Capacity.CapacityUnit)
|
||||
jaeger.RecordError(ctx, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查换算系数是否为0,防止除零错误
|
||||
if conversion.ConversionFactor == 0 {
|
||||
err = gerror.Newf("换算系数为0:%s→%s,请检查换算规则配置",
|
||||
privateSku.Capacity.CapacityUnit, location.Capacity.CapacityUnit)
|
||||
jaeger.RecordError(ctx, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 向上取整计算(整入整出:同SKU合箱后取整,不足一箱按一箱计)
|
||||
convertedQty := int(math.Ceil(float64(totalQty) / conversion.ConversionFactor))
|
||||
totalCapacity += convertedQty
|
||||
|
||||
g.Log().Debugf(ctx, "单位换算: %d%s ÷ %.2f = %d%s",
|
||||
totalQty, privateSku.Capacity.CapacityUnit,
|
||||
conversion.ConversionFactor, convertedQty, location.Capacity.CapacityUnit)
|
||||
}
|
||||
|
||||
currentCapacity := totalCapacity
|
||||
|
||||
// 5. 更新库位容量
|
||||
err = dao.Location.UpdateCapacity(ctx, locationId, currentCapacity)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "更新库位容量失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "库位容量更新成功: locationId=%s, 当前容量=%d",
|
||||
locationId.Hex(), currentCapacity)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if !success {
|
||||
return fmt.Errorf("获取库位容量锁失败: %v", err)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 6. 触发向上汇总到库区(在锁外执行,避免嵌套锁时间过长)
|
||||
if zoneId != nil && !zoneId.IsZero() {
|
||||
if syncErr := s.SyncCapacityToZone(ctx, zoneId, capacityUnitType); syncErr != nil {
|
||||
g.Log().Errorf(ctx, "同步库区容量失败: %v", syncErr)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// SyncCapacityToZone 同步容量到库区(带Redis分布式锁)
|
||||
func (s *capacity) SyncCapacityToZone(ctx context.Context, zoneId *bson.ObjectID, unitType stock.CapacityUnitType) (err error) {
|
||||
// 1. Redis分布式锁
|
||||
lockKey := fmt.Sprintf("lock:zone:%s:capacity:%s", zoneId.Hex(), unitType)
|
||||
expireSeconds := int64(30) // 30秒超时
|
||||
|
||||
success, err := redis.Lock(ctx, lockKey, expireSeconds, func(ctx context.Context) error {
|
||||
// 2. 查询该库区下所有使用该单位类型的库位
|
||||
locations, err := dao.Location.ListByZoneAndUnitType(ctx, zoneId, unitType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询库位列表失败: %v", err)
|
||||
}
|
||||
|
||||
// 3. 汇总所有库位的当前容量
|
||||
totalCapacity := 0
|
||||
maxCapacity := 0
|
||||
var capacityUnit string
|
||||
for _, loc := range locations {
|
||||
if loc.Capacity != nil {
|
||||
totalCapacity += loc.Capacity.CurrentCapacity
|
||||
maxCapacity += loc.Capacity.MaxCapacity
|
||||
if capacityUnit == "" {
|
||||
capacityUnit = loc.Capacity.CapacityUnit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 查询库区信息(获取warehouseId)
|
||||
zone, err := dao.Zone.GetOne(ctx, &dto.GetZoneReq{Id: zoneId})
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询库区失败: %v", err)
|
||||
}
|
||||
|
||||
// 5. 更新库区该单位类型的容量
|
||||
err = dao.Zone.UpdateCapacityByUnitType(ctx, zoneId, unitType, totalCapacity, maxCapacity, capacityUnit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新库区容量失败: %v", err)
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "库区容量同步成功: zoneId=%s, unitType=%s, 当前容量=%d",
|
||||
zoneId.Hex(), unitType, totalCapacity)
|
||||
|
||||
// 6. 触发向上汇总到仓库
|
||||
if zone.WarehouseId != "" {
|
||||
warehouseObjId, hexErr := bson.ObjectIDFromHex(zone.WarehouseId)
|
||||
if hexErr != nil {
|
||||
g.Log().Errorf(ctx, "库区WarehouseId格式错误: %s, %v", zone.WarehouseId, hexErr)
|
||||
} else {
|
||||
if syncErr := s.SyncCapacityToWarehouse(ctx, &warehouseObjId, unitType); syncErr != nil {
|
||||
g.Log().Errorf(ctx, "同步仓库容量失败: %v", syncErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if !success {
|
||||
return fmt.Errorf("获取Redis锁失败: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// SyncCapacityToWarehouse 同步容量到仓库(带Redis分布式锁)
|
||||
func (s *capacity) SyncCapacityToWarehouse(ctx context.Context, warehouseId *bson.ObjectID, unitType stock.CapacityUnitType) (err error) {
|
||||
// 1. Redis分布式锁
|
||||
lockKey := fmt.Sprintf("lock:warehouse:%s:capacity:%s", warehouseId.Hex(), unitType)
|
||||
expireSeconds := int64(30) // 30秒超时
|
||||
|
||||
success, err := redis.Lock(ctx, lockKey, expireSeconds, func(ctx context.Context) error {
|
||||
// 2. 查询该仓库下所有库区
|
||||
zones, err := dao.Zone.ListByWarehouseAndUnitType(ctx, warehouseId.Hex())
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询库区列表失败: %v", err)
|
||||
}
|
||||
|
||||
// 3. 汇总所有库区该单位类型的容量
|
||||
totalCapacity := 0
|
||||
maxCapacity := 0
|
||||
var capacityUnit string
|
||||
for _, zone := range zones {
|
||||
if zone.Capacity != nil {
|
||||
if cap, exists := (*zone.Capacity)[unitType]; exists {
|
||||
totalCapacity += cap.CurrentCapacity
|
||||
maxCapacity += cap.MaxCapacity
|
||||
if capacityUnit == "" {
|
||||
capacityUnit = cap.CapacityUnit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 更新仓库该单位类型的容量
|
||||
err = dao.Warehouse.UpdateCapacityByUnitType(ctx, warehouseId, unitType, totalCapacity, maxCapacity, capacityUnit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新仓库容量失败: %v", err)
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "仓库容量同步成功: warehouseId=%s, unitType=%s, 当前容量=%d",
|
||||
warehouseId.Hex(), unitType, totalCapacity)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if !success {
|
||||
return fmt.Errorf("获取Redis锁失败: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ConvertWithCeil 向上取整换算(用于容量计算)
|
||||
func (s *capacity) ConvertWithCeil(fromQty int, conversionFactor float64) int {
|
||||
return int(math.Ceil(float64(fromQty) / conversionFactor))
|
||||
}
|
||||
55
service/stock/inventory_count_adjust_history_service.go
Normal file
55
service/stock/inventory_count_adjust_history_service.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
dao "assets/dao/stock"
|
||||
dto "assets/model/dto/stock"
|
||||
"context"
|
||||
|
||||
"gitea.com/red-future/common/utils"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type inventoryCountAdjustHistory struct{}
|
||||
|
||||
var InventoryCountAdjustHistory = new(inventoryCountAdjustHistory)
|
||||
|
||||
// Create 创建盘点调整历史记录
|
||||
func (s *inventoryCountAdjustHistory) Create(ctx context.Context, req *dto.CreateInventoryCountAdjustHistoryReq) (res *dto.CreateInventoryCountAdjustHistoryRes, err error) {
|
||||
ids, err := dao.InventoryCountAdjustHistory.Insert(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
id := ids[0].(bson.ObjectID)
|
||||
res = &dto.CreateInventoryCountAdjustHistoryRes{
|
||||
Id: &id,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Delete 删除盘点调整历史记录
|
||||
func (s *inventoryCountAdjustHistory) Delete(ctx context.Context, req *dto.DeleteInventoryCountAdjustHistoryReq) error {
|
||||
return dao.InventoryCountAdjustHistory.DeleteFake(ctx, req)
|
||||
}
|
||||
|
||||
// GetOne 查询单条盘点调整历史记录详情
|
||||
func (s *inventoryCountAdjustHistory) GetOne(ctx context.Context, req *dto.GetInventoryCountAdjustHistoryReq) (res *dto.GetInventoryCountAdjustHistoryRes, err error) {
|
||||
one, err := dao.InventoryCountAdjustHistory.GetOne(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = utils.Struct(one, &res)
|
||||
return
|
||||
}
|
||||
|
||||
// List 分页查询盘点调整历史列表
|
||||
func (s *inventoryCountAdjustHistory) List(ctx context.Context, req *dto.ListInventoryCountAdjustHistoryReq) (res *dto.ListInventoryCountAdjustHistoryRes, err error) {
|
||||
list, total, err := dao.InventoryCountAdjustHistory.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListInventoryCountAdjustHistoryRes{
|
||||
Total: total,
|
||||
}
|
||||
err = utils.Struct(list, &res.List)
|
||||
return
|
||||
}
|
||||
399
service/stock/inventory_count_detail_service.go
Normal file
399
service/stock/inventory_count_detail_service.go
Normal file
@@ -0,0 +1,399 @@
|
||||
// 盘点明细服务
|
||||
// 职责:盘点明细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
|
||||
}
|
||||
1025
service/stock/inventory_count_service.go
Normal file
1025
service/stock/inventory_count_service.go
Normal file
File diff suppressed because it is too large
Load Diff
42
service/stock/inventory_warning_history_service.go
Normal file
42
service/stock/inventory_warning_history_service.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// 库存预警历史服务
|
||||
// 职责:预警历史查询、删除(无Create/Update,由预警状态变更时自动归档)
|
||||
// 紧密耦合:dao.InventoryWarningHistory
|
||||
// 注意:历史记录由系统自动归档,非用户手动创建
|
||||
package service
|
||||
|
||||
import (
|
||||
dao "assets/dao/stock"
|
||||
dto "assets/model/dto/stock"
|
||||
"context"
|
||||
|
||||
"gitea.com/red-future/common/utils"
|
||||
)
|
||||
|
||||
type inventoryWarningHistory struct{}
|
||||
|
||||
var InventoryWarningHistory = new(inventoryWarningHistory)
|
||||
|
||||
func (s *inventoryWarningHistory) GetOne(ctx context.Context, req *dto.GetInventoryWarningHistoryReq) (res *dto.GetInventoryWarningHistoryRes, err error) {
|
||||
one, err := dao.InventoryWarningHistory.GetOne(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = utils.Struct(one, &res)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *inventoryWarningHistory) List(ctx context.Context, req *dto.ListInventoryWarningHistoryReq) (res *dto.ListInventoryWarningHistoryRes, err error) {
|
||||
list, total, err := dao.InventoryWarningHistory.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListInventoryWarningHistoryRes{
|
||||
Total: total,
|
||||
}
|
||||
err = utils.Struct(list, &res.List)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *inventoryWarningHistory) Delete(ctx context.Context, req *dto.DeleteInventoryWarningHistoryReq) error {
|
||||
return dao.InventoryWarningHistory.DeleteFake(ctx, req)
|
||||
}
|
||||
38
service/stock/inventory_warning_service.go
Normal file
38
service/stock/inventory_warning_service.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// 库存预警服务
|
||||
// 职责:预警查询(无Create/Update/Delete,由定时任务或库存变动触发)
|
||||
// 紧密耦合:dao.InventoryWarning
|
||||
// 注意:预警记录由系统自动生成,非用户手动创建
|
||||
package service
|
||||
|
||||
import (
|
||||
dao "assets/dao/stock"
|
||||
dto "assets/model/dto/stock"
|
||||
"context"
|
||||
|
||||
"gitea.com/red-future/common/utils"
|
||||
)
|
||||
|
||||
type inventoryWarning struct{}
|
||||
|
||||
var InventoryWarning = new(inventoryWarning)
|
||||
|
||||
func (s *inventoryWarning) GetOne(ctx context.Context, req *dto.GetInventoryWarningReq) (res *dto.GetInventoryWarningRes, err error) {
|
||||
one, err := dao.InventoryWarning.GetOne(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = utils.Struct(one, &res)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *inventoryWarning) List(ctx context.Context, req *dto.ListInventoryWarningReq) (res *dto.ListInventoryWarningRes, err error) {
|
||||
list, total, err := dao.InventoryWarning.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListInventoryWarningRes{
|
||||
Total: total,
|
||||
}
|
||||
err = utils.Struct(list, &res.List)
|
||||
return
|
||||
}
|
||||
92
service/stock/location_service.go
Normal file
92
service/stock/location_service.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// 库位服务
|
||||
// 职责:库位CRUD、状态更新、删除前检查(3个库存集合)
|
||||
// 调用链:Delete → CountStockDetailsByLocationId/CountStockBatchByLocationId/CountPrivateStockByLocationId
|
||||
// 紧密耦合:dao.Location、Capacity(容量更新入口)
|
||||
// 注意:删除前检查StockDetails/StockBatch/PrivateStock三个集合是否有库存
|
||||
package service
|
||||
|
||||
import (
|
||||
dao "assets/dao/stock"
|
||||
dto "assets/model/dto/stock"
|
||||
"context"
|
||||
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type location struct{}
|
||||
|
||||
var Location = new(location)
|
||||
|
||||
func (s *location) Create(ctx context.Context, req *dto.CreateLocationReq) (res *dto.CreateLocationRes, err error) {
|
||||
ids, err := dao.Location.Insert(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
id := ids[0].(bson.ObjectID)
|
||||
res = &dto.CreateLocationRes{
|
||||
Id: &id,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *location) Update(ctx context.Context, req *dto.UpdateLocationReq) error {
|
||||
return dao.Location.Update(ctx, req)
|
||||
}
|
||||
|
||||
func (s *location) Delete(ctx context.Context, req *dto.DeleteLocationReq) error {
|
||||
locationId := req.Id.Hex()
|
||||
// 检查库位上是否有库存(3个集合)
|
||||
stockDetailsCount, err := dao.Location.CountStockDetailsByLocationId(ctx, locationId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stockDetailsCount > 0 {
|
||||
return gerror.Newf("库位上存在%d件库存明细,无法删除", stockDetailsCount)
|
||||
}
|
||||
|
||||
stockBatchCount, err := dao.Location.CountStockBatchByLocationId(ctx, locationId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stockBatchCount > 0 {
|
||||
return gerror.Newf("库位上存在%d个批次库存,无法删除", stockBatchCount)
|
||||
}
|
||||
|
||||
privateStockCount, err := dao.Location.CountPrivateStockByLocationId(ctx, locationId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if privateStockCount > 0 {
|
||||
return gerror.Newf("库位上存在%d件实物库存批次,无法删除", privateStockCount)
|
||||
}
|
||||
|
||||
return dao.Location.DeleteFake(ctx, req)
|
||||
}
|
||||
|
||||
// UpdateStatus 更新库位状态(单独的状态修改接口)
|
||||
func (s *location) UpdateStatus(ctx context.Context, req *dto.UpdateLocationStatusReq) error {
|
||||
return dao.Location.UpdateStatus(ctx, req)
|
||||
}
|
||||
|
||||
func (s *location) GetOne(ctx context.Context, req *dto.GetLocationReq) (res *dto.GetLocationRes, err error) {
|
||||
one, err := dao.Location.GetOne(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = utils.Struct(one, &res)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *location) List(ctx context.Context, req *dto.ListLocationReq) (res *dto.ListLocationRes, err error) {
|
||||
list, total, err := dao.Location.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListLocationRes{
|
||||
Total: total,
|
||||
}
|
||||
err = utils.Struct(list, &res.List)
|
||||
return
|
||||
}
|
||||
305
service/stock/private_stock_service.go
Normal file
305
service/stock/private_stock_service.go
Normal file
@@ -0,0 +1,305 @@
|
||||
// 实物库存批次服务
|
||||
// 职责:CRUD、移库(MoveStock库位间)、调拨(TransferStock仓库间)、出库(Outbound)
|
||||
// 调用链:Controller → Create/Update/Delete → Capacity.UpdateLocationCapacity(容量重算)
|
||||
// 紧密耦合:dao.PrivateStock、dao.Warehouse/Zone/Location(获取名称)、Capacity(容量更新)
|
||||
// 注意:区别于StockDetails/StockBatch的逻辑库存,PrivateStock记录实际存放位置
|
||||
package service
|
||||
|
||||
import (
|
||||
"assets/consts/public"
|
||||
"assets/consts/stock"
|
||||
dao "assets/dao/stock"
|
||||
dto "assets/model/dto/stock"
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"gitea.com/red-future/common/db/mongo"
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type privateStock struct{}
|
||||
|
||||
// PrivateStock 实物库存批次服务
|
||||
// 职责:
|
||||
// 1. CRUD:创建、更新、删除、查询实物库存批次
|
||||
// 2. 移库(MoveStock):库位间移动
|
||||
// 3. 调拨(TransferStock):仓库间调拨
|
||||
// 4. 出库(Outbound):减少库存数量
|
||||
// 特点:记录SKU批次的实际存放位置(仓库/库区/库位)和数量,区别于StockDetails/StockBatch的逻辑库存
|
||||
var PrivateStock = new(privateStock)
|
||||
|
||||
func (s *privateStock) Create(ctx context.Context, req *dto.CreatePrivateStockReq) (res *dto.CreatePrivateStockRes, err error) {
|
||||
ids, err := dao.PrivateStock.Insert(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
id := ids[0].(bson.ObjectID)
|
||||
res = &dto.CreatePrivateStockRes{
|
||||
Id: &id,
|
||||
}
|
||||
|
||||
// 触发库位容量更新
|
||||
if req.LocationId != nil && !req.LocationId.IsZero() {
|
||||
if err := Capacity.UpdateLocationCapacity(ctx, req.LocationId); err != nil {
|
||||
// 容量更新失败不影响创建
|
||||
g.Log().Warningf(ctx, "更新库位容量失败: %v", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *privateStock) Update(ctx context.Context, req *dto.UpdatePrivateStockReq) error {
|
||||
// 查询库存信息获取LocationId
|
||||
stock, err := dao.PrivateStock.GetOne(ctx, &dto.GetPrivateStockReq{Id: req.Id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = dao.PrivateStock.Update(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 触发库位容量更新
|
||||
if stock.LocationID != nil && !stock.LocationID.IsZero() {
|
||||
if err := Capacity.UpdateLocationCapacity(ctx, stock.LocationID); err != nil {
|
||||
g.Log().Warningf(ctx, "更新库位容量失败: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *privateStock) Delete(ctx context.Context, req *dto.DeletePrivateStockReq) error {
|
||||
// 查询库存信息获取LocationId(用于删除后更新容量)
|
||||
stockInfo, err := dao.PrivateStock.GetOne(ctx, &dto.GetPrivateStockReq{Id: req.Id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := dao.PrivateStock.DeleteFake(ctx, req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 触发库位容量更新
|
||||
if stockInfo.LocationID != nil && !stockInfo.LocationID.IsZero() {
|
||||
if capErr := Capacity.UpdateLocationCapacity(ctx, stockInfo.LocationID); capErr != nil {
|
||||
g.Log().Warningf(ctx, "删除库存后更新库位容量失败: %v", capErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *privateStock) GetOne(ctx context.Context, req *dto.GetPrivateStockReq) (res *dto.GetPrivateStockRes, err error) {
|
||||
one, err := dao.PrivateStock.GetOne(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = utils.Struct(one, &res)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *privateStock) List(ctx context.Context, req *dto.ListPrivateStockReq) (res *dto.ListPrivateStockRes, err error) {
|
||||
list, total, err := dao.PrivateStock.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListPrivateStockRes{
|
||||
Total: total,
|
||||
}
|
||||
err = utils.Struct(list, &res.List)
|
||||
return
|
||||
}
|
||||
|
||||
// MoveStock 移库(库位间移动)
|
||||
func (s *privateStock) MoveStock(ctx context.Context, req *dto.MoveStockReq) error {
|
||||
// 只支持PrivateStock移库,StockDetails/StockBatch是逻辑库存无位置信息
|
||||
if req.StockType != stock.StockLocationTypePrivateStock {
|
||||
return gerror.New("移库操作仅支持实物库存批次(PrivateStock),明细和批次库存为逻辑库存无位置信息")
|
||||
}
|
||||
|
||||
// 验证源库位和目标库位不能相同
|
||||
if req.FromLocationId.Hex() == req.ToLocationId.Hex() {
|
||||
return gerror.New("源库位和目标库位不能相同")
|
||||
}
|
||||
|
||||
// 验证库存是否存在且位置匹配
|
||||
privateStock, err := dao.PrivateStock.GetOne(ctx, &dto.GetPrivateStockReq{Id: req.StockId})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if privateStock.LocationID == nil || privateStock.LocationID.Hex() != req.FromLocationId.Hex() {
|
||||
return gerror.New("库存当前位置与源库位不匹配")
|
||||
}
|
||||
|
||||
// 获取目标库位信息
|
||||
toLocation, err := dao.Location.GetOne(ctx, &dto.GetLocationReq{Id: req.ToLocationId})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新库存的库位信息
|
||||
filter := bson.M{"_id": req.StockId}
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"locationId": req.ToLocationId,
|
||||
"locationCode": toLocation.LocationCode,
|
||||
"locationName": toLocation.LocationName,
|
||||
"locationType": toLocation.LocationType,
|
||||
"lastMovedAt": gtime.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
_, err = mongo.DB().Update(ctx, filter, update, public.PrivateStockCollection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 触发源库位和目标库位容量更新
|
||||
if req.FromLocationId != nil && !req.FromLocationId.IsZero() {
|
||||
if err := Capacity.UpdateLocationCapacity(ctx, req.FromLocationId); err != nil {
|
||||
g.Log().Warningf(ctx, "更新源库位容量失败: %v", err)
|
||||
}
|
||||
}
|
||||
if req.ToLocationId != nil && !req.ToLocationId.IsZero() {
|
||||
if err := Capacity.UpdateLocationCapacity(ctx, req.ToLocationId); err != nil {
|
||||
g.Log().Warningf(ctx, "更新目标库位容量失败: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TransferStock 调拨(仓库间调拨)
|
||||
func (s *privateStock) TransferStock(ctx context.Context, req *dto.TransferStockReq) error {
|
||||
// 只支持PrivateStock调拨,StockDetails/StockBatch是逻辑库存无位置信息
|
||||
if req.StockType != stock.StockLocationTypePrivateStock {
|
||||
return gerror.New("调拨操作仅支持实物库存批次(PrivateStock),明细和批次库存为逻辑库存无位置信息")
|
||||
}
|
||||
|
||||
// 验证源仓库和目标仓库不能相同
|
||||
if req.FromWarehouseId.Hex() == req.ToWarehouseId.Hex() {
|
||||
return gerror.New("源仓库和目标仓库不能相同")
|
||||
}
|
||||
|
||||
// 验证库存是否存在且仓库匹配
|
||||
privateStock, err := dao.PrivateStock.GetOne(ctx, &dto.GetPrivateStockReq{Id: req.StockId})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if privateStock.WarehouseID == nil || privateStock.WarehouseID.Hex() != req.FromWarehouseId.Hex() {
|
||||
return gerror.New("库存当前仓库与源仓库不匹配")
|
||||
}
|
||||
|
||||
// 获取目标仓库信息
|
||||
toWarehouse, err := dao.Warehouse.GetOne(ctx, &dto.GetWarehouseReq{Id: req.ToWarehouseId})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 构建更新字段
|
||||
updateFields := bson.M{
|
||||
"warehouseId": req.ToWarehouseId,
|
||||
"warehouseCode": toWarehouse.WarehouseCode,
|
||||
"warehouseName": toWarehouse.WarehouseName,
|
||||
"lastMovedAt": gtime.Now(),
|
||||
}
|
||||
|
||||
// 未指定目标库区/库位时清空旧值,避免残留指向源仓库
|
||||
unsetFields := bson.M{}
|
||||
|
||||
// 如果指定了目标库区
|
||||
if req.ToZoneId != nil && !req.ToZoneId.IsZero() {
|
||||
toZone, err := dao.Zone.GetOne(ctx, &dto.GetZoneReq{Id: req.ToZoneId})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updateFields["zoneId"] = req.ToZoneId
|
||||
updateFields["zoneCode"] = toZone.ZoneCode
|
||||
updateFields["zoneName"] = toZone.ZoneName
|
||||
updateFields["zoneType"] = toZone.ZoneType
|
||||
} else {
|
||||
unsetFields["zoneId"] = ""
|
||||
unsetFields["zoneCode"] = ""
|
||||
unsetFields["zoneName"] = ""
|
||||
unsetFields["zoneType"] = ""
|
||||
}
|
||||
|
||||
// 如果指定了目标库位
|
||||
if req.ToLocationId != nil && !req.ToLocationId.IsZero() {
|
||||
toLocation, err := dao.Location.GetOne(ctx, &dto.GetLocationReq{Id: req.ToLocationId})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updateFields["locationId"] = req.ToLocationId
|
||||
updateFields["locationCode"] = toLocation.LocationCode
|
||||
updateFields["locationName"] = toLocation.LocationName
|
||||
updateFields["locationType"] = toLocation.LocationType
|
||||
} else {
|
||||
unsetFields["locationId"] = ""
|
||||
unsetFields["locationCode"] = ""
|
||||
unsetFields["locationName"] = ""
|
||||
unsetFields["locationType"] = ""
|
||||
}
|
||||
|
||||
filter := bson.M{"_id": req.StockId}
|
||||
update := bson.M{"$set": updateFields}
|
||||
if len(unsetFields) > 0 {
|
||||
update["$unset"] = unsetFields
|
||||
}
|
||||
|
||||
_, err = mongo.DB().Update(ctx, filter, update, public.PrivateStockCollection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 触发库位容量更新(调拨后涉及库位变化时)
|
||||
// 更新源库位
|
||||
if privateStock.LocationID != nil && !privateStock.LocationID.IsZero() {
|
||||
if err := Capacity.UpdateLocationCapacity(ctx, privateStock.LocationID); err != nil {
|
||||
g.Log().Warningf(ctx, "更新源库位容量失败: %v", err)
|
||||
}
|
||||
}
|
||||
// 更新目标库位
|
||||
if req.ToLocationId != nil && !req.ToLocationId.IsZero() {
|
||||
if err := Capacity.UpdateLocationCapacity(ctx, req.ToLocationId); err != nil {
|
||||
g.Log().Warningf(ctx, "更新目标库位容量失败: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Outbound 实物库存批次出库
|
||||
func (s *privateStock) Outbound(ctx context.Context, req *dto.OutboundPrivateStockReq) error {
|
||||
// 只支持PrivateStock出库,StockDetails/StockBatch是逻辑库存无位置信息
|
||||
if req.StockType != stock.StockLocationTypePrivateStock {
|
||||
return gerror.New("出库操作仅支持实物库存批次(PrivateStock),明细和批次库存为逻辑库存无位置信息")
|
||||
}
|
||||
|
||||
// 验证库存是否存在
|
||||
privateStock, err := dao.PrivateStock.GetOne(ctx, &dto.GetPrivateStockReq{Id: req.StockId})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 验证可用数量是否足够
|
||||
if privateStock.AvailableQty < req.OutboundQty {
|
||||
return gerror.Newf("可用库存不足:当前可用%d,需要出库%d", privateStock.AvailableQty, req.OutboundQty)
|
||||
}
|
||||
|
||||
// 使用IncrementAvailableQty原子更新(传负数表示减少)
|
||||
err = dao.PrivateStock.IncrementAvailableQty(ctx, req.StockId, -req.OutboundQty)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 触发库位容量更新
|
||||
if privateStock.LocationID != nil && !privateStock.LocationID.IsZero() {
|
||||
if err := Capacity.UpdateLocationCapacity(ctx, privateStock.LocationID); err != nil {
|
||||
g.Log().Warningf(ctx, "更新库位容量失败: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
60
service/stock/stock_batch_service.go
Normal file
60
service/stock/stock_batch_service.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// 批次库存服务(逻辑库存)
|
||||
// 职责:批次CRUD、列表查询
|
||||
// 紧密耦合:dao.StockBatch
|
||||
// 注意:区别于PrivateStock的实物库存,批次库存是逻辑概念,不记录物理位置
|
||||
package service
|
||||
|
||||
import (
|
||||
dao "assets/dao/stock"
|
||||
dto "assets/model/dto/stock"
|
||||
"context"
|
||||
|
||||
"gitea.com/red-future/common/utils"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type stockBatch struct{}
|
||||
|
||||
// StockBatch 批次服务
|
||||
var StockBatch = new(stockBatch)
|
||||
|
||||
func (s *stockBatch) Create(ctx context.Context, req *dto.CreateBatchReq) (res *dto.CreateBatchRes, err error) {
|
||||
ids, err := dao.StockBatch.Insert(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
id := ids[0].(bson.ObjectID)
|
||||
res = &dto.CreateBatchRes{
|
||||
Id: &id,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *stockBatch) Update(ctx context.Context, req *dto.UpdateBatchReq) error {
|
||||
return dao.StockBatch.Update(ctx, req)
|
||||
}
|
||||
|
||||
func (s *stockBatch) Delete(ctx context.Context, req *dto.DeleteBatchReq) error {
|
||||
return dao.StockBatch.DeleteFake(ctx, req)
|
||||
}
|
||||
|
||||
func (s *stockBatch) GetOne(ctx context.Context, req *dto.GetBatchReq) (res *dto.GetBatchRes, err error) {
|
||||
one, err := dao.StockBatch.GetOneById(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = utils.Struct(one, &res)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *stockBatch) List(ctx context.Context, req *dto.ListBatchReq) (res *dto.ListBatchRes, err error) {
|
||||
list, total, err := dao.StockBatch.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListBatchRes{
|
||||
Total: total,
|
||||
}
|
||||
err = utils.Struct(list, &res.List)
|
||||
return
|
||||
}
|
||||
39
service/stock/stock_details_service.go
Normal file
39
service/stock/stock_details_service.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// 库存明细服务(逻辑库存)
|
||||
// 职责:库存明细查询(无Create/Update/Delete,由StockManage.StockOperation管理)
|
||||
// 紧密耦合:dao.StockDetails
|
||||
// 注意:区别于PrivateStock的实物库存,明细库存是逻辑概念,不记录物理位置
|
||||
package service
|
||||
|
||||
import (
|
||||
dao "assets/dao/stock"
|
||||
dto "assets/model/dto/stock"
|
||||
"context"
|
||||
|
||||
"gitea.com/red-future/common/utils"
|
||||
)
|
||||
|
||||
type stockDetails struct{}
|
||||
|
||||
// StockDetails 库存服务
|
||||
var StockDetails = new(stockDetails)
|
||||
|
||||
func (s *stockDetails) GetOne(ctx context.Context, req *dto.GetStockDetailsReq) (res *dto.GetStockDetailsRes, err error) {
|
||||
one, err := dao.StockDetails.GetOneById(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = utils.Struct(one, &res)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *stockDetails) List(ctx context.Context, req *dto.ListStockDetailsReq) (res *dto.ListStockDetailsRes, err error) {
|
||||
list, total, err := dao.StockDetails.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListStockDetailsRes{
|
||||
Total: total,
|
||||
}
|
||||
err = utils.Struct(list, &res.List)
|
||||
return
|
||||
}
|
||||
329
service/stock/stock_manage_service.go
Normal file
329
service/stock/stock_manage_service.go
Normal file
@@ -0,0 +1,329 @@
|
||||
// 库存管理服务(Stock公共库存)
|
||||
// 职责:入库/出库操作,支持明细模式(StockDetails)和批次模式(StockBatch)
|
||||
// 调用链:Controller → StockOperation → stockPublishMessage → NATS → AddStock(消费者)
|
||||
// 紧密耦合:dao.StockDetails、dao.StockBatch、dao.AssetSku(更新库存数)、common/message(NATS发布)
|
||||
// 注意:移库/调拨是PrivateStock专属操作,不在此实现
|
||||
package service
|
||||
|
||||
import (
|
||||
"assets/consts/public"
|
||||
"assets/consts/stock"
|
||||
assetDao "assets/dao/asset"
|
||||
dao "assets/dao/stock"
|
||||
assetDto "assets/model/dto/asset"
|
||||
stockDto "assets/model/dto/stock"
|
||||
assetEntity "assets/model/entity/asset"
|
||||
entity "assets/model/entity/stock"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
"gitea.com/red-future/common/redis"
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type stockManage struct{}
|
||||
|
||||
// StockManage 库存管理服务(Stock公共库存)
|
||||
// 职责:
|
||||
// 1. 入库/出库:StockDetails(明细模式)和 StockBatch(批次模式)的库存操作
|
||||
// 2. 库存查询表单字段生成
|
||||
// 注意:移库(MoveStock)和调拨(TransferStock)是PrivateStock专属操作,不要在这里实现
|
||||
var StockManage = new(stockManage)
|
||||
|
||||
// GetStockFormFields 获取库存操作表单字段
|
||||
func (s *stockManage) GetStockFormFields(ctx context.Context, req *stockDto.GetStockFormFieldsReq) (*stockDto.GetStockFormFieldsRes, error) {
|
||||
// 获取资产SKU信息
|
||||
assetSku, err := assetDao.AssetSku.GetOne(ctx, &assetDto.GetAssetSkuReq{Id: req.AssetSkuId}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields := make([]map[string]interface{}, 0)
|
||||
// Stock 字段在两种模式下都显示
|
||||
fields = append(fields, map[string]interface{}{
|
||||
"name": "stock",
|
||||
"label": "库存数量",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"min": 1,
|
||||
})
|
||||
// 如果是批次模式(2),添加批次相关字段
|
||||
if !g.IsEmpty(assetSku.StockMode) && assetSku.StockMode == stock.StockModeBatch {
|
||||
fields = append(fields, []map[string]interface{}{
|
||||
{
|
||||
"name": "batchNo",
|
||||
"label": "批次号",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"default": gconv.Int(gtime.Now().Format("20060102") + "0001"),
|
||||
"maxLength": 12,
|
||||
},
|
||||
{
|
||||
"name": "productionDate",
|
||||
"label": "生产日期",
|
||||
"type": "date",
|
||||
},
|
||||
{
|
||||
"name": "expiryDate",
|
||||
"label": "过期日期",
|
||||
"type": "date",
|
||||
},
|
||||
{
|
||||
"name": "expiryWarningDate",
|
||||
"label": "临期预警时间",
|
||||
"type": "date",
|
||||
},
|
||||
}...)
|
||||
}
|
||||
return &stockDto.GetStockFormFieldsRes{
|
||||
StockMode: assetSku.StockMode,
|
||||
Fields: fields,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StockOperation 库存操作入口(入库/出库)
|
||||
// 根据SKU的StockMode区分明细模式和批次模式,计算差值后发布消息到NATS
|
||||
func (s *stockManage) StockOperation(ctx context.Context, req *stockDto.StockOperationReq) (err error) {
|
||||
assetSku, err := assetDao.AssetSku.GetOne(ctx, &assetDto.GetAssetSkuReq{Id: req.AssetSkuId}, false)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !assetSku.UnlimitedStock && req.Stock >= 0 {
|
||||
var stockId *bson.ObjectID
|
||||
count := 0
|
||||
if assetSku.StockMode == stock.StockModeDetail {
|
||||
_count, err := dao.StockDetails.GetStockCountBySkuId(ctx, assetSku.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count = gconv.Int(_count)
|
||||
}
|
||||
if assetSku.StockMode == stock.StockModeBatch {
|
||||
if g.IsEmpty(req.BatchNo) {
|
||||
return gerror.New("批次号不能为空")
|
||||
}
|
||||
getOne, err := dao.StockBatch.GetOne(ctx, req.BatchNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !g.IsEmpty(getOne) {
|
||||
stockId = getOne.Id
|
||||
count = getOne.BatchQty
|
||||
}
|
||||
}
|
||||
stockCount := 0
|
||||
operationType := ""
|
||||
if count != req.Stock {
|
||||
if count > req.Stock {
|
||||
stockCount = count - req.Stock
|
||||
operationType = "del"
|
||||
} else {
|
||||
stockCount = req.Stock - count
|
||||
operationType = "add"
|
||||
}
|
||||
}
|
||||
if !g.IsEmpty(operationType) && stockCount > 0 {
|
||||
if err = s.stockPublishMessage(ctx, assetSku, stockId, stockCount, operationType, req); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// stockPublishMessage 发布库存变更消息到NATS
|
||||
// 消费者接收后执行实际的入库/出库操作(异步解耦)
|
||||
func (s *stockManage) stockPublishMessage(ctx context.Context, assetSku *assetEntity.AssetSku, stockId *bson.ObjectID, stockCount int, operationType string, req *stockDto.StockOperationReq) (err error) {
|
||||
// 用户信息
|
||||
user, err := utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
publishMessage := stockDto.StockPublishMessage{
|
||||
AssetId: assetSku.AssetId.Hex(),
|
||||
AssetSkuId: assetSku.Id.Hex(),
|
||||
TenantId: user.TenantId,
|
||||
UserName: user.UserName,
|
||||
StockCount: stockCount,
|
||||
OperationType: operationType,
|
||||
Metadata: assetSku.SpecValues,
|
||||
StockMode: int(assetSku.StockMode),
|
||||
BatchNo: req.BatchNo,
|
||||
ProductionDate: req.ProductionDate,
|
||||
ExpiryDate: req.ExpiryDate,
|
||||
ExpiryWarningDate: req.ExpiryWarningDate,
|
||||
}
|
||||
if !g.IsEmpty(stockId) && !stockId.IsZero() {
|
||||
publishMessage.StockId = stockId.Hex()
|
||||
}
|
||||
// 发布到 NATS
|
||||
//plugin, err := message.GetMsgPlugin(ctx, message.MessageNATS)
|
||||
//if err != nil {
|
||||
// return gerror.Newf("NATS插件未就绪: %v", err)
|
||||
//}
|
||||
//err = plugin.Publish(ctx, &message.NatsPublishMsgConfig{
|
||||
// QueueName: public.StockDetailGroupName,
|
||||
// Durable: true,
|
||||
// Data: publishMessage,
|
||||
//})
|
||||
//_, err = message.PublishMessage(ctx, &message.RedisMessageConfig{StreamKey: public.StockDetailStreamKey}, publishMessage)
|
||||
//plugin, err := message.GetMsgPlugin(message.MessageRedis)
|
||||
//if err != nil {
|
||||
// return err
|
||||
//}
|
||||
//err = plugin.Publish(ctx, &message.RedisPublishMsgConfig{
|
||||
// QueueName: public.StockDetailQueueName,
|
||||
// Data: publishMessage,
|
||||
//})
|
||||
return
|
||||
}
|
||||
|
||||
// AddStock NATS消费者调用,执行实际的入库/出库操作
|
||||
// 使用Redis分布式锁防止并发冲突,支持明细模式和批次模式
|
||||
func (s *stockManage) AddStock(ctx context.Context, msg map[string]interface{}) error {
|
||||
assetId, err := bson.ObjectIDFromHex(gconv.String(msg["assetId"]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
assetSkuId, err := bson.ObjectIDFromHex(gconv.String(msg["assetSkuId"]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stockId := bson.ObjectID{}
|
||||
if !g.IsEmpty(msg["stockId"]) {
|
||||
stockId, err = bson.ObjectIDFromHex(gconv.String(msg["stockId"]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
userName := gconv.String(msg["userName"])
|
||||
tenantId := gconv.Float64(msg["tenantId"])
|
||||
stockCount := gconv.Int(msg["stockCount"])
|
||||
operationType := gconv.String(msg["operationType"])
|
||||
metadata := gconv.Maps(msg["metadata"])
|
||||
stockMode := stock.StockMode(gconv.Int(msg["stockMode"]))
|
||||
batchNo := gconv.String(msg["batchNo"])
|
||||
productionDate := gtime.New(msg["productionDate"])
|
||||
expiryDate := gtime.New(msg["expiryDate"])
|
||||
expiryWarningDate := gtime.New(msg["expiryWarningDate"])
|
||||
// 设置 userId 和 tenantId 到 ctx
|
||||
ctx = context.WithValue(ctx, "userName", userName)
|
||||
ctx = context.WithValue(ctx, "tenantId", tenantId)
|
||||
// 获取redis-租户存储-锁key
|
||||
fileLockKey := fmt.Sprintf(public.StockDetailLockKey, assetSkuId)
|
||||
success, err := redis.Lock(ctx, fileLockKey, int64(60), func(ctx context.Context) error {
|
||||
if operationType == "add" {
|
||||
if stockMode == stock.StockModeBatch {
|
||||
if !stockId.IsZero() {
|
||||
batch := stockDto.UpdateBatchReq{
|
||||
Id: &stockId,
|
||||
BatchQty: stockCount,
|
||||
AvailableQty: stockCount,
|
||||
}
|
||||
if err := dao.StockBatch.Update(ctx, &batch); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
batch := stockDto.CreateBatchReq{
|
||||
AssetId: &assetId,
|
||||
AssetSkuId: &assetSkuId,
|
||||
Status: stock.BatchStatusActive,
|
||||
Metadata: metadata,
|
||||
BatchNo: batchNo,
|
||||
BatchQty: stockCount,
|
||||
AvailableQty: stockCount,
|
||||
ProductionDate: productionDate,
|
||||
ExpiryDate: expiryDate,
|
||||
ExpiryWarningDate: expiryWarningDate,
|
||||
}
|
||||
if _, err := dao.StockBatch.Insert(ctx, &batch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if stockMode == stock.StockModeDetail {
|
||||
// 创建指定数量的库存
|
||||
var stockInterfaces []interface{}
|
||||
for i := 0; i < stockCount; i++ {
|
||||
stockInterfaces = append(stockInterfaces, entity.StockDetails{
|
||||
AssetId: &assetId,
|
||||
AssetSkuId: &assetSkuId,
|
||||
Status: stock.StockStatusAvailable,
|
||||
Metadata: metadata,
|
||||
})
|
||||
}
|
||||
// 批量插入数据库
|
||||
if _, err = dao.StockDetails.BatchInsert(ctx, stockInterfaces); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if operationType == "del" {
|
||||
if stockMode == stock.StockModeBatch {
|
||||
stockCount = 0 - stockCount
|
||||
// 更新批次
|
||||
batch := stockDto.UpdateBatchReq{
|
||||
Id: &stockId,
|
||||
BatchQty: stockCount,
|
||||
AvailableQty: stockCount,
|
||||
}
|
||||
if err := dao.StockBatch.Update(ctx, &batch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if stockMode == stock.StockModeDetail {
|
||||
// 分页查询所有库存明细,收集所有ID
|
||||
var allStockIds []*bson.ObjectID
|
||||
pageSize := int64(50)
|
||||
for pageNum := int64(1); ; pageNum++ {
|
||||
details, total, err := dao.StockDetails.List(ctx,
|
||||
&stockDto.ListStockDetailsReq{
|
||||
AssetSkuId: &assetSkuId,
|
||||
Status: stock.StockStatusAvailable,
|
||||
Page: &beans.Page{PageNum: pageNum, PageSize: pageSize},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pageNum == 1 && int(total) < stockCount {
|
||||
return gerror.New("可操作库存数量不足")
|
||||
}
|
||||
// 收集当前页的ID
|
||||
for _, detail := range details {
|
||||
if detail.Id != nil && !detail.Id.IsZero() {
|
||||
allStockIds = append(allStockIds, detail.Id)
|
||||
if len(allStockIds) >= stockCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(allStockIds) >= stockCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
// 根据ID批量删除库存
|
||||
delCount, err := dao.StockDetails.DeleteManyByIds(ctx, allStockIds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if delCount != int64(stockCount) {
|
||||
return gerror.New("删除库存数量不匹配")
|
||||
}
|
||||
stockCount = 0 - stockCount
|
||||
}
|
||||
}
|
||||
return assetDao.AssetSku.Update(ctx, &assetDto.UpdateAssetSkuReq{Id: &assetSkuId, Stock: stockCount})
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !success {
|
||||
return fmt.Errorf("获取库存操作锁失败: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
103
service/stock/unit_conversion_service.go
Normal file
103
service/stock/unit_conversion_service.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package service
|
||||
|
||||
// unit_conversion_service.go
|
||||
// 单位换算服务层
|
||||
//
|
||||
// 职责:
|
||||
// 1. GetConversionFactor - 获取单位换算系数
|
||||
// 查询UnitConversion表,返回fromUnit→toUnit的换算系数
|
||||
//
|
||||
// 2. ConvertWithCeil - 向上取整换算(用于容量计算)
|
||||
// 公式:ceil(fromQty / factor)
|
||||
// 示例:50瓶 ÷ 20(瓶/箱)= 2.5 → ceil = 3箱
|
||||
//
|
||||
// 3. ConvertExact - 精确换算(用于出入库数量计算)
|
||||
// 公式:fromQty / factor
|
||||
// 示例:50瓶 ÷ 20(瓶/箱)= 2.5箱
|
||||
//
|
||||
// 核心逻辑:
|
||||
// - 从UnitConversion表查询换算系数
|
||||
// - 向上取整确保不满一个单位也占用空间
|
||||
// - 只能在同一CapacityUnitType(枚举类型)内换算
|
||||
|
||||
import (
|
||||
dao "assets/dao/stock"
|
||||
dto "assets/model/dto/stock"
|
||||
entity "assets/model/entity/stock"
|
||||
"context"
|
||||
|
||||
"gitea.com/red-future/common/utils"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
var UnitConversion = new(unitConversion)
|
||||
|
||||
type unitConversion struct{}
|
||||
|
||||
// Create 创建单位换算规则
|
||||
func (s *unitConversion) Create(ctx context.Context, req *dto.CreateUnitConversionReq) (res *dto.CreateUnitConversionRes, err error) {
|
||||
var conversion *entity.UnitConversion
|
||||
if err = utils.Struct(req, &conversion); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ids, err := dao.UnitConversion.Insert(ctx, conversion)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
id := ids[0].(bson.ObjectID)
|
||||
res = &dto.CreateUnitConversionRes{
|
||||
Id: &id,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新单位换算规则
|
||||
func (s *unitConversion) Update(ctx context.Context, req *dto.UpdateUnitConversionReq) (err error) {
|
||||
update := bson.M{}
|
||||
|
||||
if req.ConversionCode != "" {
|
||||
update["conversionCode"] = req.ConversionCode
|
||||
}
|
||||
if req.ConversionName != "" {
|
||||
update["conversionName"] = req.ConversionName
|
||||
}
|
||||
if req.UnitType != "" {
|
||||
update["unitType"] = req.UnitType
|
||||
}
|
||||
if req.FromUnit != "" {
|
||||
update["fromUnit"] = req.FromUnit
|
||||
}
|
||||
if req.ToUnit != "" {
|
||||
update["toUnit"] = req.ToUnit
|
||||
}
|
||||
if req.ConversionFactor > 0 {
|
||||
update["conversionFactor"] = req.ConversionFactor
|
||||
}
|
||||
if req.Remark != "" {
|
||||
update["remark"] = req.Remark
|
||||
}
|
||||
|
||||
err = dao.UnitConversion.Update(ctx, req.Id, update)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete 删除单位换算规则
|
||||
func (s *unitConversion) Delete(ctx context.Context, req *dto.DeleteUnitConversionReq) (err error) {
|
||||
err = dao.UnitConversion.DeleteFake(ctx, req.Id)
|
||||
return
|
||||
}
|
||||
|
||||
// List 查询单位换算列表
|
||||
func (s *unitConversion) List(ctx context.Context, req *dto.ListUnitConversionReq) (res *dto.ListUnitConversionRes, err error) {
|
||||
list, err := dao.UnitConversion.List(ctx, req.UnitType, req.FromUnit, req.ToUnit)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
res = &dto.ListUnitConversionRes{
|
||||
List: list,
|
||||
}
|
||||
return
|
||||
}
|
||||
96
service/stock/warehouse_service.go
Normal file
96
service/stock/warehouse_service.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// 仓库服务
|
||||
// 职责:仓库CRUD、状态更新(联动库区/库位状态)
|
||||
// 调用链:UpdateStatus → BatchUpdateZoneStatus/BatchUpdateLocationStatus(状态联动)
|
||||
// 紧密耦合:dao.Warehouse、dao.Zone/dao.Location(级联状态更新、删除前检查)
|
||||
// 注意:删除前检查是否存在库区,启用/禁用联动子表状态
|
||||
package service
|
||||
|
||||
import (
|
||||
stockConst "assets/consts/stock"
|
||||
dao "assets/dao/stock"
|
||||
dto "assets/model/dto/stock"
|
||||
"context"
|
||||
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type warehouse struct{}
|
||||
|
||||
var Warehouse = new(warehouse)
|
||||
|
||||
func (s *warehouse) Create(ctx context.Context, req *dto.CreateWarehouseReq) (res *dto.CreateWarehouseRes, err error) {
|
||||
ids, err := dao.Warehouse.Insert(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
id := ids[0].(bson.ObjectID)
|
||||
res = &dto.CreateWarehouseRes{
|
||||
Id: &id,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *warehouse) Update(ctx context.Context, req *dto.UpdateWarehouseReq) error {
|
||||
return dao.Warehouse.Update(ctx, req)
|
||||
}
|
||||
|
||||
func (s *warehouse) Delete(ctx context.Context, req *dto.DeleteWarehouseReq) error {
|
||||
warehouseId := req.Id.Hex()
|
||||
count, err := dao.Warehouse.CountZonesByWarehouseId(ctx, warehouseId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return gerror.Newf("仓库下存在%d个库区,无法删除", count)
|
||||
}
|
||||
return dao.Warehouse.DeleteFake(ctx, req)
|
||||
}
|
||||
|
||||
// UpdateStatus 更新仓库状态(单独的状态修改接口,联动子表)
|
||||
func (s *warehouse) UpdateStatus(ctx context.Context, req *dto.UpdateWarehouseStatusReq) (err error) {
|
||||
warehouseId := req.Id.Hex()
|
||||
if err = dao.Warehouse.UpdateStatus(ctx, req); err != nil {
|
||||
return
|
||||
}
|
||||
if req.Status == stockConst.WarehouseStatusEnabled {
|
||||
// 启用:只恢复Disabled状态的库区/库位,保留InUse等其他状态
|
||||
if _, err = dao.Warehouse.BatchUpdateZoneStatus(ctx, warehouseId, stockConst.ZoneStatusEnabled, stockConst.ZoneStatusDisabled); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err = dao.Warehouse.BatchUpdateLocationStatus(ctx, warehouseId, stockConst.LocationStatusIdle, stockConst.LocationStatusDisabled); err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 禁用:所有库区/库位统一设为Disabled
|
||||
if _, err = dao.Warehouse.BatchUpdateZoneStatus(ctx, warehouseId, stockConst.ZoneStatusDisabled); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err = dao.Warehouse.BatchUpdateLocationStatus(ctx, warehouseId, stockConst.LocationStatusDisabled); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *warehouse) GetOne(ctx context.Context, req *dto.GetWarehouseReq) (res *dto.GetWarehouseRes, err error) {
|
||||
one, err := dao.Warehouse.GetOne(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = utils.Struct(one, &res)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *warehouse) List(ctx context.Context, req *dto.ListWarehouseReq) (res *dto.ListWarehouseRes, err error) {
|
||||
list, total, err := dao.Warehouse.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListWarehouseRes{
|
||||
Total: total,
|
||||
}
|
||||
err = utils.Struct(list, &res.List)
|
||||
return
|
||||
}
|
||||
93
service/stock/zone_service.go
Normal file
93
service/stock/zone_service.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// 库区服务
|
||||
// 职责:库区CRUD、状态更新(联动库位状态)
|
||||
// 调用链:UpdateStatus → BatchUpdateLocationStatus(状态联动)
|
||||
// 紧密耦合:dao.Zone、dao.Location(级联状态更新、删除前检查)
|
||||
// 注意:删除前检查是否存在库位,启用/禁用联动库位状态
|
||||
package service
|
||||
|
||||
import (
|
||||
"assets/consts/stock"
|
||||
dao "assets/dao/stock"
|
||||
dto "assets/model/dto/stock"
|
||||
"context"
|
||||
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type zone struct{}
|
||||
|
||||
var Zone = new(zone)
|
||||
|
||||
func (s *zone) Create(ctx context.Context, req *dto.CreateZoneReq) (res *dto.CreateZoneRes, err error) {
|
||||
ids, err := dao.Zone.Insert(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
id := ids[0].(bson.ObjectID)
|
||||
res = &dto.CreateZoneRes{
|
||||
Id: &id,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *zone) Update(ctx context.Context, req *dto.UpdateZoneReq) error {
|
||||
return dao.Zone.Update(ctx, req)
|
||||
}
|
||||
|
||||
func (s *zone) Delete(ctx context.Context, req *dto.DeleteZoneReq) error {
|
||||
zoneId := req.Id.Hex()
|
||||
// 删除前检查:是否存在关联的库位
|
||||
count, err := dao.Zone.CountLocationsByZoneId(ctx, zoneId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return gerror.Newf("库区下存在%d个库位,无法删除", count)
|
||||
}
|
||||
return dao.Zone.DeleteFake(ctx, req)
|
||||
}
|
||||
|
||||
// UpdateStatus 更新库区状态(单独的状态修改接口,联动子表)
|
||||
func (s *zone) UpdateStatus(ctx context.Context, req *dto.UpdateZoneStatusReq) (err error) {
|
||||
zoneId := req.Id.Hex()
|
||||
// 1. 更新库区状态
|
||||
if err = dao.Zone.UpdateStatus(ctx, req); err != nil {
|
||||
return
|
||||
}
|
||||
// 2. 联动更新库位状态
|
||||
if req.Status == stock.ZoneStatusEnabled {
|
||||
// 启用:只恢复Disabled状态的库位,保留InUse等其他状态
|
||||
if _, err = dao.Zone.BatchUpdateLocationStatus(ctx, zoneId, stock.LocationStatusIdle, stock.LocationStatusDisabled); err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 禁用:所有库位统一设为Disabled
|
||||
if _, err = dao.Zone.BatchUpdateLocationStatus(ctx, zoneId, stock.LocationStatusDisabled); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *zone) GetOne(ctx context.Context, req *dto.GetZoneReq) (res *dto.GetZoneRes, err error) {
|
||||
one, err := dao.Zone.GetOne(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = utils.Struct(one, &res)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *zone) List(ctx context.Context, req *dto.ListZoneReq) (res *dto.ListZoneRes, err error) {
|
||||
list, total, err := dao.Zone.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListZoneRes{
|
||||
Total: total,
|
||||
}
|
||||
err = utils.Struct(list, &res.List)
|
||||
return
|
||||
}
|
||||
Reference in New Issue
Block a user