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 TestStockOutHandler_FullFlow(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SO001") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Main") product := testutil.CreateTestProduct(db, shop.ID, "Whiskey") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 先建立库存 db.Create(&model.Inventory{ ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product.ID, Quantity: 100, }) // 1. 创建出库单 w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ { "product_id": product.ID, "quantity": 15.0, "unit_price": 20.0, }, }, }) require.Equal(t, http.StatusCreated, w.Code) orderID := extractID(w) assert.NotZero(t, orderID) data := parseResponse(w)["data"].(map[string]interface{}) assert.Equal(t, "draft", data["status"]) assert.NotEmpty(t, data["order_no"]) // 2. 提交 w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", orderID), token, nil) require.Equal(t, http.StatusOK, w.Code) // 3. 获取详情 w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-out/orders/%d", orderID), token, nil) require.Equal(t, http.StatusOK, w.Code) detailData := parseResponse(w)["data"].(map[string]interface{}) assert.Equal(t, "pending", detailData["status"]) // 4. 审核通过 w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", orderID), token, nil) require.Equal(t, http.StatusOK, w.Code) // 5. 验证库存减少 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(85), inv.Quantity) } func TestStockOutHandler_InsufficientStock(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SO002") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product := testutil.CreateTestProduct(db, shop.ID, "Vodka") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 库存只有 5 db.Create(&model.Inventory{ ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product.ID, Quantity: 5, }) // 创建出库单要出 10 w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ {"product_id": product.ID, "quantity": 10.0}, }, }) orderID := extractID(w) makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", orderID), token, nil) // 审核应该失败 w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", orderID), token, nil) assert.Equal(t, http.StatusBadRequest, w.Code) } func TestStockOutHandler_List(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SO003") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product := testutil.CreateTestProduct(db, shop.ID, "Beer") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 先建立库存 db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product.ID, Quantity: 100}) // 创建 2 个出库单 for i := 0; i < 2; i++ { makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ {"product_id": product.ID, "quantity": 1.0}, }, }) } w := makeRequest(r, "GET", "/api/v1/stock-out/orders", token, nil) assert.Equal(t, http.StatusOK, w.Code) resp := parseResponse(w) assert.Equal(t, float64(2), resp["total"].(float64)) } // 按商品编码精确反查出库单(明细 product_code = ?) func TestStockOutHandler_List_FilterByProductCode(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SO_CODE") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product := testutil.CreateTestProduct(db, shop.ID, "Beer") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ {"product_id": product.ID, "product_code": "ZXZ-FIND", "quantity": 1.0}, }, }) makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ {"product_id": product.ID, "product_code": "OTHER-CODE", "quantity": 1.0}, }, }) // 精确命中 1 单 w := makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=ZXZ-FIND", token, nil) require.Equal(t, http.StatusOK, w.Code) assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) // 不存在的编码 → 0;前缀不算精确(不命中) w = makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=ZXZ", token, nil) require.Equal(t, http.StatusOK, w.Code) assert.Equal(t, float64(0), parseResponse(w)["total"].(float64)) } func TestStockOutHandler_List_FilterByKeyword(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SO011") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product := testutil.CreateTestProduct(db, shop.ID, "Beer") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 往来单位:海底捞客户 partner := &model.Partner{Code: "C001", Name: "海底捞客户", Type: "customer"} partner.ShopID = shop.ID require.NoError(t, db.Create(partner).Error) // 单据1:挂往来单位 w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "partner_id": partner.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{{"product_id": product.ID, "quantity": 1.0}}, }) require.Equal(t, http.StatusCreated, w.Code) order1No := parseResponse(w)["data"].(map[string]interface{})["order_no"].(string) // 单据2:无往来单位 w = makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{{"product_id": product.ID, "quantity": 1.0}}, }) require.Equal(t, http.StatusCreated, w.Code) // 按往来单位名搜索 w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=海底捞", token, nil) assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) // 按单号搜索 w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword="+order1No, token, nil) assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) // 无关键词 w = makeRequest(r, "GET", "/api/v1/stock-out/orders", token, nil) assert.Equal(t, float64(2), parseResponse(w)["total"].(float64)) } func TestStockOutHandler_List_FilterByProductName(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SO012") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") maotai := testutil.CreateTestProduct(db, shop.ID, "飞天茅台") lafei := testutil.CreateTestProduct(db, shop.ID, "拉菲红酒") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{{"product_id": maotai.ID, "quantity": 1.0}}, }) require.Equal(t, http.StatusCreated, w.Code) w = makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{{"product_id": lafei.ID, "quantity": 1.0}}, }) require.Equal(t, http.StatusCreated, w.Code) // 按酒名模糊反查 → 各命中 1 单 w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=茅台", token, nil) assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=红酒", token, nil) assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) // 无关键词 → 两单都在 w = makeRequest(r, "GET", "/api/v1/stock-out/orders", token, nil) assert.Equal(t, float64(2), parseResponse(w)["total"].(float64)) } // 回归:历史导入出库单的明细 product_id 指向占位商品「历史导入占位」,真实酒名只在快照列。 // 修复前 COALESCE 被占位名劫持搜不到,修复后「p.name OR product_name」任一命中。 func TestStockOutHandler_List_FilterByImportedSnapshotName(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SO012B") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) placeholder := &model.Product{ TenantBase: model.TenantBase{ShopID: shop.ID}, Name: "历史导入占位", Code: "HIST-PLACEHOLDER", } require.NoError(t, db.Create(placeholder).Error) order := &model.StockOutOrder{ TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: "CK-IMPORT-001", WarehouseID: warehouse.ID, OperatorID: user.ID, Status: "approved", OrderDate: model.Date{Time: time.Now()}, } require.NoError(t, db.Create(order).Error) require.NoError(t, db.Create(&model.StockOutItem{ OrderID: order.ID, ShopID: shop.ID, ProductID: placeholder.ID, ProductCode: "ZXZ000010", ProductName: "飞天茅台", Quantity: 5.0, }).Error) // 按真实酒名搜 → 命中导入单(修复前为 0) w := makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=茅台", token, nil) assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) } func TestStockOutHandler_Reject(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SO004") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product := testutil.CreateTestProduct(db, shop.ID, "Rum") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product.ID, Quantity: 100}) w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ {"product_id": product.ID, "quantity": 5.0}, }, }) orderID := extractID(w) makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", orderID), token, nil) // 驳回 w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/reject", orderID), token, nil) assert.Equal(t, http.StatusOK, w.Code) // 验证状态 w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-out/orders/%d", orderID), token, nil) detailData := parseResponse(w)["data"].(map[string]interface{}) assert.Equal(t, "rejected", detailData["status"]) } func TestStockOutHandler_GetNotFound(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SO005") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) w := makeRequest(r, "GET", "/api/v1/stock-out/orders/99999", token, nil) assert.Equal(t, http.StatusNotFound, w.Code) } func TestStockOutHandler_NoAuth(t *testing.T) { db := testutil.SetupTestDB() r := setupProtectedRouter(db) w := makeRequest(r, "GET", "/api/v1/stock-out/orders", "", nil) assert.Equal(t, http.StatusUnauthorized, w.Code) w = makeRequest(r, "POST", "/api/v1/stock-out/orders", "", map[string]interface{}{ "warehouse_id": 1, }) assert.Equal(t, http.StatusUnauthorized, w.Code) } func TestStockOutHandler_TenantIsolation(t *testing.T) { db := testutil.SetupTestDB() shopA := testutil.CreateTestShop(db, "SO_A") userA := testutil.CreateTestUser(db, shopA.ID, "adminA", "pass", "admin") warehouseA := testutil.CreateTestWarehouse(db, shopA.ID, "WA") productA := testutil.CreateTestProduct(db, shopA.ID, "BrandyA") tokenA := getAuthToken(userA.ID, shopA.ID, "admin") shopB := testutil.CreateTestShop(db, "SO_B") userB := testutil.CreateTestUser(db, shopB.ID, "adminB", "pass", "admin") tokenB := getAuthToken(userB.ID, shopB.ID, "admin") r := setupProtectedRouter(db) // 建立门店 A 的库存 db.Create(&model.Inventory{ ShopID: shopA.ID, WarehouseID: &warehouseA.ID, ProductID: &productA.ID, Quantity: 50, }) // 门店 A 创建出库单 w := makeRequest(r, "POST", "/api/v1/stock-out/orders", tokenA, map[string]interface{}{ "warehouse_id": warehouseA.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ {"product_id": productA.ID, "quantity": 5.0}, }, }) require.Equal(t, http.StatusCreated, w.Code) orderAID := extractID(w) // 门店 B 看不到门店 A 的出库单 w = makeRequest(r, "GET", "/api/v1/stock-out/orders", tokenB, nil) resp := parseResponse(w) assert.Equal(t, float64(0), resp["total"].(float64)) // 门店 B 不能获取门店 A 的出库单详情 w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-out/orders/%d", orderAID), tokenB, nil) assert.Equal(t, http.StatusNotFound, w.Code) // 门店 B 不能提交门店 A 的出库单 w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", orderAID), tokenB, nil) assert.Equal(t, http.StatusBadRequest, w.Code) } func TestStockOutHandler_Create_MissingWarehouse(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SO006") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 缺少 warehouse_id(必填) w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ "order_date": time.Now().Format(time.RFC3339), }) assert.Equal(t, http.StatusBadRequest, w.Code) } func TestStockOutHandler_Approve_NotPending(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SO007") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product := testutil.CreateTestProduct(db, shop.ID, "Cognac") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product.ID, Quantity: 50}) // 创建但不提交 w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ {"product_id": product.ID, "quantity": 5.0}, }, }) orderID := extractID(w) // 直接审核(应该失败,因为是 draft 状态) w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", orderID), token, nil) assert.Equal(t, http.StatusBadRequest, w.Code) } func TestStockOutHandler_InventoryLog_OnApprove(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SO008") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product := testutil.CreateTestProduct(db, shop.ID, "Moutai") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product.ID, Quantity: 100}) // 创建、提交、审核 w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{{"product_id": product.ID, "quantity": 20.0}}, }) orderID := extractID(w) makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", orderID), token, nil) makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", orderID), token, nil) // 验证库存流水 var logs []model.InventoryLog db.Where("shop_id = ? AND product_id = ? AND direction = 'out'", shop.ID, product.ID).Find(&logs) require.Len(t, logs, 1) assert.Equal(t, float64(20), logs[0].Quantity) assert.Equal(t, float64(100), logs[0].QtyBefore) assert.Equal(t, float64(80), logs[0].QtyAfter) }