Dockerfile

This commit is contained in:
2026-03-18 10:18:03 +08:00
parent 5c5dbc7420
commit b65f3439f3
189 changed files with 19027 additions and 0 deletions

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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