ce1cbf404c
后端: - fix(backend): InventoryLog 增加 Product/Warehouse 关联,Logs 接口加 Preload 修复流水记录显示"-" - feat(backend): 新增 /inventory/batches 批次追踪接口 - feat(backend): 新增财务记录、编号规则接口(finance、number_rule handler) - fix(backend): service/stock_test.go 修复 model.Date 类型错误 前端: - feat(client): 入库/出库管理 Tab 调换顺序(审核在前),审核 Tab 加新建按钮,列表 Tab 无按钮 - feat(client): 入库单/出库单增加详情弹窗,显示完整单据信息和商品明细 - feat(client): 完善出库单新建表单(StockOutFormScreen),支持选客户/仓库/商品 - fix(client): StockInItem/StockOutItem.fromJson 修复读取嵌套 product 对象而非平铺字段 - feat(client): 批次追踪页面(BatchTrackingScreen)及侧边栏入口 - feat(client): 财务管理、盘点、编号规则接入真实后端 API - fix(client): 入库/出库审核通过后 invalidate inventoryListProvider 刷新库存 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
297 lines
8.6 KiB
Go
297 lines
8.6 KiB
Go
package service
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/wangjia/jiu/backend/internal/model"
|
|
"github.com/wangjia/jiu/backend/testutil"
|
|
)
|
|
|
|
|
|
func TestStockService_ApproveStockIn_Success(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "STOCK001")
|
|
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Main Warehouse")
|
|
product := testutil.CreateTestProduct(db, shop.ID, "Beer")
|
|
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
|
|
|
// 创建入库单
|
|
order := model.StockInOrder{
|
|
TenantBase: model.TenantBase{ShopID: shop.ID},
|
|
OrderNo: "IN20240101000001",
|
|
WarehouseID: warehouse.ID,
|
|
OperatorID: user.ID,
|
|
Status: "pending",
|
|
OrderDate: model.Date{Time: time.Now()},
|
|
Items: []model.StockInItem{
|
|
{
|
|
ShopID: shop.ID,
|
|
ProductID: product.ID,
|
|
Quantity: 10,
|
|
UnitPrice: 5.0,
|
|
TotalPrice: 50.0,
|
|
},
|
|
},
|
|
}
|
|
require.NoError(t, db.Create(&order).Error)
|
|
|
|
svc := NewStockService(db)
|
|
err := svc.ApproveStockIn(shop.ID, order.ID, user.ID)
|
|
require.NoError(t, err)
|
|
|
|
// 验证单据状态
|
|
var updated model.StockInOrder
|
|
db.First(&updated, order.ID)
|
|
assert.Equal(t, "approved", updated.Status)
|
|
|
|
// 验证库存
|
|
var inv model.Inventory
|
|
db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
|
|
shop.ID, warehouse.ID, product.ID).First(&inv)
|
|
assert.Equal(t, float64(10), inv.Quantity)
|
|
|
|
// 验证库存流水
|
|
var logs []model.InventoryLog
|
|
db.Where("shop_id = ? AND product_id = ?", shop.ID, product.ID).Find(&logs)
|
|
require.Len(t, logs, 1)
|
|
assert.Equal(t, "in", logs[0].Direction)
|
|
assert.Equal(t, float64(10), logs[0].Quantity)
|
|
assert.Equal(t, float64(0), logs[0].QtyBefore)
|
|
assert.Equal(t, float64(10), logs[0].QtyAfter)
|
|
}
|
|
|
|
func TestStockService_ApproveStockIn_NotPending(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "STOCK002")
|
|
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
|
|
_ = testutil.CreateTestProduct(db, shop.ID, "Wine")
|
|
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
|
|
|
order := model.StockInOrder{
|
|
TenantBase: model.TenantBase{ShopID: shop.ID},
|
|
OrderNo: "IN20240101000002",
|
|
WarehouseID: warehouse.ID,
|
|
OperatorID: user.ID,
|
|
Status: "draft", // 非 pending 状态
|
|
OrderDate: model.Date{Time: time.Now()},
|
|
}
|
|
require.NoError(t, db.Create(&order).Error)
|
|
|
|
svc := NewStockService(db)
|
|
err := svc.ApproveStockIn(shop.ID, order.ID, user.ID)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "not in pending status")
|
|
}
|
|
|
|
func TestStockService_ApproveStockOut_Success(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "STOCK003")
|
|
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
|
|
product := testutil.CreateTestProduct(db, shop.ID, "Whiskey")
|
|
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
|
|
|
// 先入库
|
|
inv := model.Inventory{
|
|
ShopID: shop.ID,
|
|
WarehouseID: warehouse.ID,
|
|
ProductID: product.ID,
|
|
Quantity: 20,
|
|
}
|
|
require.NoError(t, db.Create(&inv).Error)
|
|
|
|
// 创建出库单
|
|
order := model.StockOutOrder{
|
|
TenantBase: model.TenantBase{ShopID: shop.ID},
|
|
OrderNo: "OUT20240101000001",
|
|
WarehouseID: warehouse.ID,
|
|
OperatorID: user.ID,
|
|
Status: "pending",
|
|
OrderDate: model.Date{Time: time.Now()},
|
|
Items: []model.StockOutItem{
|
|
{
|
|
ShopID: shop.ID,
|
|
ProductID: product.ID,
|
|
Quantity: 5,
|
|
UnitPrice: 10.0,
|
|
TotalPrice: 50.0,
|
|
},
|
|
},
|
|
}
|
|
require.NoError(t, db.Create(&order).Error)
|
|
|
|
svc := NewStockService(db)
|
|
err := svc.ApproveStockOut(shop.ID, order.ID, user.ID)
|
|
require.NoError(t, err)
|
|
|
|
// 验证库存减少
|
|
var updatedInv model.Inventory
|
|
db.First(&updatedInv, inv.ID)
|
|
assert.Equal(t, float64(15), updatedInv.Quantity)
|
|
|
|
// 验证单据状态
|
|
var updatedOrder model.StockOutOrder
|
|
db.First(&updatedOrder, order.ID)
|
|
assert.Equal(t, "approved", updatedOrder.Status)
|
|
}
|
|
|
|
func TestStockService_ApproveStockOut_InsufficientStock(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "STOCK004")
|
|
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
|
|
product := testutil.CreateTestProduct(db, shop.ID, "Vodka")
|
|
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
|
|
|
// 库存只有 3
|
|
inv := model.Inventory{
|
|
ShopID: shop.ID,
|
|
WarehouseID: warehouse.ID,
|
|
ProductID: product.ID,
|
|
Quantity: 3,
|
|
}
|
|
require.NoError(t, db.Create(&inv).Error)
|
|
|
|
// 要出库 10
|
|
order := model.StockOutOrder{
|
|
TenantBase: model.TenantBase{ShopID: shop.ID},
|
|
OrderNo: "OUT20240101000002",
|
|
WarehouseID: warehouse.ID,
|
|
OperatorID: user.ID,
|
|
Status: "pending",
|
|
OrderDate: model.Date{Time: time.Now()},
|
|
Items: []model.StockOutItem{
|
|
{
|
|
ShopID: shop.ID,
|
|
ProductID: product.ID,
|
|
Quantity: 10,
|
|
},
|
|
},
|
|
}
|
|
require.NoError(t, db.Create(&order).Error)
|
|
|
|
svc := NewStockService(db)
|
|
err := svc.ApproveStockOut(shop.ID, order.ID, user.ID)
|
|
assert.Error(t, err)
|
|
assert.ErrorIs(t, err, ErrInsufficientStock)
|
|
}
|
|
|
|
func TestStockService_ApproveStockOut_ProductNotInInventory(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "STOCK005")
|
|
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
|
|
product := testutil.CreateTestProduct(db, shop.ID, "Rum")
|
|
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
|
|
|
// 没有创建库存记录
|
|
order := model.StockOutOrder{
|
|
TenantBase: model.TenantBase{ShopID: shop.ID},
|
|
OrderNo: "OUT20240101000003",
|
|
WarehouseID: warehouse.ID,
|
|
OperatorID: user.ID,
|
|
Status: "pending",
|
|
OrderDate: model.Date{Time: time.Now()},
|
|
Items: []model.StockOutItem{
|
|
{
|
|
ShopID: shop.ID,
|
|
ProductID: product.ID,
|
|
Quantity: 5,
|
|
},
|
|
},
|
|
}
|
|
require.NoError(t, db.Create(&order).Error)
|
|
|
|
svc := NewStockService(db)
|
|
err := svc.ApproveStockOut(shop.ID, order.ID, user.ID)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "not in inventory")
|
|
}
|
|
|
|
func TestStockService_GenerateOrderNo(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "STOCK006")
|
|
|
|
svc := NewStockService(db)
|
|
no, err := svc.GenerateOrderNo(shop.ID, "stock_in")
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, no)
|
|
// 单号格式: prefix + date + 6位序号
|
|
assert.Contains(t, no, "st")
|
|
|
|
// 第二次生成序号应递增
|
|
no2, err := svc.GenerateOrderNo(shop.ID, "stock_in")
|
|
require.NoError(t, err)
|
|
assert.NotEqual(t, no, no2)
|
|
}
|
|
|
|
// TestStockService_GenerateOrderNo_Sequential 验证顺序调用生成唯一递增单号
|
|
// 注:并发安全由 MySQL 的事务锁保证,SQLite in-memory 不模拟此场景
|
|
func TestStockService_GenerateOrderNo_Sequential(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "STOCK007")
|
|
|
|
svc := NewStockService(db)
|
|
|
|
const count = 5
|
|
seen := map[string]bool{}
|
|
|
|
for i := 0; i < count; i++ {
|
|
no, err := svc.GenerateOrderNo(shop.ID, "stock_in")
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, no)
|
|
assert.False(t, seen[no], fmt.Sprintf("duplicate order no: %s", no))
|
|
seen[no] = true
|
|
}
|
|
|
|
assert.Len(t, seen, count, "should generate exactly %d unique order numbers", count)
|
|
}
|
|
|
|
func TestStockService_MultipleStockIn_AccumulatesInventory(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "STOCK008")
|
|
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
|
|
product := testutil.CreateTestProduct(db, shop.ID, "Gin")
|
|
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
|
|
|
svc := NewStockService(db)
|
|
|
|
// 第一次入库
|
|
order1 := model.StockInOrder{
|
|
TenantBase: model.TenantBase{ShopID: shop.ID},
|
|
OrderNo: "IN001",
|
|
WarehouseID: warehouse.ID,
|
|
OperatorID: user.ID,
|
|
Status: "pending",
|
|
OrderDate: model.Date{Time: time.Now()},
|
|
Items: []model.StockInItem{
|
|
{ShopID: shop.ID, ProductID: product.ID, Quantity: 10},
|
|
},
|
|
}
|
|
require.NoError(t, db.Create(&order1).Error)
|
|
require.NoError(t, svc.ApproveStockIn(shop.ID, order1.ID, user.ID))
|
|
|
|
// 第二次入库
|
|
order2 := model.StockInOrder{
|
|
TenantBase: model.TenantBase{ShopID: shop.ID},
|
|
OrderNo: "IN002",
|
|
WarehouseID: warehouse.ID,
|
|
OperatorID: user.ID,
|
|
Status: "pending",
|
|
OrderDate: model.Date{Time: time.Now()},
|
|
Items: []model.StockInItem{
|
|
{ShopID: shop.ID, ProductID: product.ID, Quantity: 5},
|
|
},
|
|
}
|
|
require.NoError(t, db.Create(&order2).Error)
|
|
require.NoError(t, svc.ApproveStockIn(shop.ID, order2.ID, user.ID))
|
|
|
|
// 总库存应该是 15
|
|
var inv model.Inventory
|
|
db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
|
|
shop.ID, warehouse.ID, product.ID).First(&inv)
|
|
assert.Equal(t, float64(15), inv.Quantity)
|
|
}
|