@@ -0,0 +1,190 @@
package handler
import (
"fmt"
"net/http"
"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 TestStockReturn_StockOut_OwnAllowed ( t * testing . T ) {
db := testutil . SetupTestDB ( )
shop := testutil . CreateTestShop ( db , "RET_SO" )
op := testutil . CreateTestUser ( db , shop . ID , "op" , "pass" , "operator" )
wh := testutil . CreateTestWarehouse ( db , shop . ID , "仓" )
prod := testutil . CreateTestProduct ( db , shop . ID , "茅台" )
opToken := getAuthToken ( op . ID , shop . ID , "operator" )
r := setupProtectedRouter ( db )
db . Create ( & model . Inventory { ShopID : shop . ID , WarehouseID : & wh . ID , ProductID : & prod . ID , Quantity : 100 } )
// 操作员建出库单(本人) → 提交 → 审核
w := makeRequest ( r , "POST" , "/api/v1/stock-out/orders" , opToken , map [ string ] interface { } {
"warehouse_id" : wh . ID , "order_date" : time . Now ( ) . Format ( time . RFC3339 ) ,
"items" : [ ] map [ string ] interface { } { { "product_id" : prod . ID , "quantity" : 15.0 , "unit_price" : 20.0 } } ,
} )
require . Equal ( t , http . StatusCreated , w . Code )
orderID := extractID ( w )
makeRequest ( r , "PUT" , fmt . Sprintf ( "/api/v1/stock-out/orders/%d/submit" , orderID ) , opToken , nil )
makeRequest ( r , "PUT" , fmt . Sprintf ( "/api/v1/stock-out/orders/%d/approve" , orderID ) , opToken , nil )
var item model . StockOutItem
require . NoError ( t , db . Where ( "order_id = ?" , orderID ) . First ( & item ) . Error )
// 库存已扣到 85
var sum float64
db . Model ( & model . Inventory { } ) . Where ( "shop_id = ? AND product_id = ? AND deleted_at IS NULL" , shop . ID , prod . ID ) .
Select ( "COALESCE(SUM(quantity),0)" ) . Scan ( & sum )
require . Equal ( t , float64 ( 85 ) , sum )
// 本人退单 → 200
w = makeRequest ( r , "POST" , fmt . Sprintf ( "/api/v1/stock-out/orders/%d/return" , orderID ) , opToken ,
map [ string ] interface { } { "item_ids" : [ ] uint64 { item . ID } } )
assert . Equal ( t , http . StatusOK , w . Code )
// 库存加回到 100
db . Model ( & model . Inventory { } ) . Where ( "shop_id = ? AND product_id = ? AND deleted_at IS NULL" , shop . ID , prod . ID ) .
Select ( "COALESCE(SUM(quantity),0)" ) . Scan ( & sum )
assert . Equal ( t , float64 ( 100 ) , sum )
// 加回是「新建一条库存行」(数量=退单数量 15)
var addBack int64
db . Model ( & model . Inventory { } ) .
Where ( "shop_id = ? AND product_id = ? AND quantity = 15 AND deleted_at IS NULL" , shop . ID , prod . ID ) .
Count ( & addBack )
assert . Equal ( t , int64 ( 1 ) , addBack , "应新建一条数量=15 的加回库存行" )
// 库存流水:一条 in / stock_out_return, 85→100
var lg model . InventoryLog
require . NoError ( t , db . Where ( "shop_id = ? AND ref_type = 'stock_out_return' AND direction = 'in'" , shop . ID ) .
First ( & lg ) . Error )
assert . Equal ( t , float64 ( 15 ) , lg . Quantity )
assert . Equal ( t , float64 ( 85 ) , lg . QtyBefore )
assert . Equal ( t , float64 ( 100 ) , lg . QtyAfter )
// 明细标记已退 + 单据 return_state=full
db . First ( & item , item . ID )
assert . Equal ( t , item . Quantity , item . ReturnedQuantity )
var order model . StockOutOrder
db . First ( & order , orderID )
assert . Equal ( t , "full" , order . ReturnState )
// 应收冲减:存在一条负向 receivable
var neg int64
db . Model ( & model . FinanceRecord { } ) .
Where ( "shop_id = ? AND type = 'receivable' AND amount < 0 AND ref_type = 'stock_out_return'" , shop . ID ) .
Count ( & neg )
assert . Equal ( t , int64 ( 1 ) , neg )
}
func TestStockReturn_StockIn_AdminOnly_AndRemovesInventory ( t * testing . T ) {
db := testutil . SetupTestDB ( )
shop := testutil . CreateTestShop ( db , "RET_SI" )
admin := testutil . CreateTestUser ( db , shop . ID , "admin" , "pass" , "admin" )
op := testutil . CreateTestUser ( db , shop . ID , "op" , "pass" , "operator" )
wh := testutil . CreateTestWarehouse ( db , shop . ID , "仓" )
prod := testutil . CreateTestProduct ( db , shop . ID , "五粮液" )
adminToken := getAuthToken ( admin . ID , shop . ID , "admin" )
opToken := getAuthToken ( op . ID , shop . ID , "operator" )
r := setupProtectedRouter ( db )
// 入库单 → 审核 → 建库存
w := makeRequest ( r , "POST" , "/api/v1/stock-in/orders" , adminToken , map [ string ] interface { } {
"warehouse_id" : wh . ID , "order_date" : time . Now ( ) . Format ( time . RFC3339 ) ,
"items" : [ ] map [ string ] interface { } { { "product_id" : prod . ID , "quantity" : 10.0 , "unit_price" : 50.0 } } ,
} )
require . Equal ( t , http . StatusCreated , w . Code )
orderID := extractID ( w )
makeRequest ( r , "PUT" , fmt . Sprintf ( "/api/v1/stock-in/orders/%d/submit" , orderID ) , adminToken , nil )
makeRequest ( r , "PUT" , fmt . Sprintf ( "/api/v1/stock-in/orders/%d/approve" , orderID ) , adminToken , nil )
var item model . StockInItem
require . NoError ( t , db . Where ( "order_id = ?" , orderID ) . First ( & item ) . Error )
pid := item . ProductID // 序列号模型:入库为每行新建独立 product
var sum float64
db . Model ( & model . Inventory { } ) . Where ( "shop_id = ? AND product_id = ? AND deleted_at IS NULL" , shop . ID , pid ) .
Select ( "COALESCE(SUM(quantity),0)" ) . Scan ( & sum )
require . Equal ( t , float64 ( 10 ) , sum )
// 操作员退入库单 → 403
w = makeRequest ( r , "POST" , fmt . Sprintf ( "/api/v1/stock-in/orders/%d/return" , orderID ) , opToken ,
map [ string ] interface { } { "item_ids" : [ ] uint64 { item . ID } } )
assert . Equal ( t , http . StatusForbidden , w . Code )
// 管理员退入库单 → 200,库存被删
w = makeRequest ( r , "POST" , fmt . Sprintf ( "/api/v1/stock-in/orders/%d/return" , orderID ) , adminToken ,
map [ string ] interface { } { "item_ids" : [ ] uint64 { item . ID } } )
assert . Equal ( t , http . StatusOK , w . Code )
db . Model ( & model . Inventory { } ) . Where ( "shop_id = ? AND product_id = ? AND deleted_at IS NULL" , shop . ID , pid ) .
Select ( "COALESCE(SUM(quantity),0)" ) . Scan ( & sum )
assert . Equal ( t , float64 ( 0 ) , sum )
// 入库建的那条库存行已被软删(active 中查不到)
var active int64
db . Model ( & model . Inventory { } ) .
Where ( "shop_id = ? AND stock_in_item_id = ? AND deleted_at IS NULL" , shop . ID , item . ID ) .
Count ( & active )
assert . Equal ( t , int64 ( 0 ) , active , "入库退单后对应库存行应被删除" )
// 库存流水:一条 out / stock_in_return,数量=10
var lg model . InventoryLog
require . NoError ( t , db . Where ( "shop_id = ? AND ref_type = 'stock_in_return' AND direction = 'out'" , shop . ID ) .
First ( & lg ) . Error )
assert . Equal ( t , float64 ( 10 ) , lg . Quantity )
db . First ( & item , item . ID )
assert . Equal ( t , item . Quantity , item . ReturnedQuantity )
var order model . StockInOrder
db . First ( & order , orderID )
assert . Equal ( t , "full" , order . ReturnState )
// 应付冲减
var neg int64
db . Model ( & model . FinanceRecord { } ) .
Where ( "shop_id = ? AND type = 'payable' AND amount < 0 AND ref_type = 'stock_in_return'" , shop . ID ) .
Count ( & neg )
assert . Equal ( t , int64 ( 1 ) , neg )
}
func TestStockReturn_StockIn_BlockedWhenSold ( t * testing . T ) {
db := testutil . SetupTestDB ( )
shop := testutil . CreateTestShop ( db , "RET_BLK" )
admin := testutil . CreateTestUser ( db , shop . ID , "admin" , "pass" , "admin" )
wh := testutil . CreateTestWarehouse ( db , shop . ID , "仓" )
prod := testutil . CreateTestProduct ( db , shop . ID , "汾酒" )
adminToken := getAuthToken ( admin . ID , shop . ID , "admin" )
r := setupProtectedRouter ( db )
// 入库 10 → 审核
w := makeRequest ( r , "POST" , "/api/v1/stock-in/orders" , adminToken , map [ string ] interface { } {
"warehouse_id" : wh . ID , "order_date" : time . Now ( ) . Format ( time . RFC3339 ) ,
"items" : [ ] map [ string ] interface { } { { "product_id" : prod . ID , "quantity" : 10.0 , "unit_price" : 50.0 } } ,
} )
inID := extractID ( w )
makeRequest ( r , "PUT" , fmt . Sprintf ( "/api/v1/stock-in/orders/%d/submit" , inID ) , adminToken , nil )
makeRequest ( r , "PUT" , fmt . Sprintf ( "/api/v1/stock-in/orders/%d/approve" , inID ) , adminToken , nil )
var inItem model . StockInItem
db . Where ( "order_id = ?" , inID ) . First ( & inItem )
// 出库 4(消耗入库新建的那个 product)→ 审核
w = makeRequest ( r , "POST" , "/api/v1/stock-out/orders" , adminToken , map [ string ] interface { } {
"warehouse_id" : wh . ID , "order_date" : time . Now ( ) . Format ( time . RFC3339 ) ,
"items" : [ ] map [ string ] interface { } { { "product_id" : inItem . ProductID , "quantity" : 4.0 , "unit_price" : 80.0 } } ,
} )
outID := extractID ( w )
makeRequest ( r , "PUT" , fmt . Sprintf ( "/api/v1/stock-out/orders/%d/submit" , outID ) , adminToken , nil )
makeRequest ( r , "PUT" , fmt . Sprintf ( "/api/v1/stock-out/orders/%d/approve" , outID ) , adminToken , nil )
// 退入库单 → 400(库存已部分出库)
w = makeRequest ( r , "POST" , fmt . Sprintf ( "/api/v1/stock-in/orders/%d/return" , inID ) , adminToken ,
map [ string ] interface { } { "item_ids" : [ ] uint64 { inItem . ID } } )
assert . Equal ( t , http . StatusBadRequest , w . Code )
}