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 TestStockInHandler_FullFlow(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI001") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Main") product := testutil.CreateTestProduct(db, shop.ID, "Test Beer") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 1. 创建入库单(草稿) w := makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ { "product_name": "Test Beer", "series": "普通", "spec": "500ml", "quantity": 10.0, "unit_price": 5.0, }, }, }) require.Equal(t, http.StatusCreated, w.Code) orderID := extractID(w) assert.NotZero(t, orderID) // 验证状态是 draft respData := parseResponse(w)["data"].(map[string]interface{}) assert.Equal(t, "draft", respData["status"]) assert.NotEmpty(t, respData["order_no"]) // 2. 提交(draft → pending) w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", orderID), token, nil) require.Equal(t, http.StatusOK, w.Code) // 3. 获取单据详情 w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-in/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-in/orders/%d/approve", orderID), token, nil) require.Equal(t, http.StatusOK, w.Code) // 5. 验证库存变化:入库为明细新建了独立产品,库存指向它(不是预设的 product) var inv model.Inventory db.Where("shop_id = ? AND warehouse_id = ?", shop.ID, warehouse.ID).First(&inv) assert.Equal(t, float64(10), inv.Quantity) assert.NotZero(t, inv.ProductID) assert.NotEqual(t, product.ID, inv.ProductID) // 6. 验证库存流水 var logs []model.InventoryLog db.Where("shop_id = ? AND product_id = ?", shop.ID, inv.ProductID).Find(&logs) require.Len(t, logs, 1) assert.Equal(t, "in", logs[0].Direction) assert.Equal(t, float64(10), logs[0].Quantity) } // 入库明细带 sale_price 时,为该行新建的独立产品应写入 product.SalePrice(建议售价)。 func TestStockInHandler_Create_SalePriceToProduct(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SISALE") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Main") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) w := makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ { "product_name": "茅台飞天", "series": "53度", "spec": "500ml", "quantity": 3.0, "unit_price": 2680.0, "sale_price": 2980.0, }, }, }) require.Equal(t, http.StatusCreated, w.Code) orderID := extractID(w) // 找到该单据明细新建的独立产品,核对进价/售价 var item model.StockInItem require.NoError(t, db.Where("order_id = ?", orderID).First(&item).Error) require.NotZero(t, item.ProductID) var prod model.Product require.NoError(t, db.Where("id = ? AND shop_id = ?", item.ProductID, shop.ID).First(&prod).Error) assert.Equal(t, 2680.0, prod.PurchasePrice) assert.Equal(t, 2980.0, prod.SalePrice) // sale_price 不落 stock_in_items(gorm:"-"),仅作产品建库入参 assert.Equal(t, "茅台飞天", prod.Name) } func TestStockInHandler_List(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI002") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product := testutil.CreateTestProduct(db, shop.ID, "Wine") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 创建 2 个入库单 for i := 0; i < 2; i++ { makeRequest(r, "POST", "/api/v1/stock-in/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, "unit_price": 10.0}, }, }) } w := makeRequest(r, "GET", "/api/v1/stock-in/orders", token, nil) assert.Equal(t, http.StatusOK, w.Code) resp := parseResponse(w) assert.Equal(t, float64(2), resp["total"].(float64)) } func TestStockInHandler_Summary(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SISUM") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") wh := testutil.CreateTestWarehouse(db, shop.ID, "W") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) for i := 0; i < 2; i++ { makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{ "warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{{"product_name": "A", "quantity": 2.0, "unit_price": 100.0}}, }) } var first model.StockInOrder db.Where("shop_id = ?", shop.ID).Order("id ASC").First(&first) makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", first.ID), token, nil) w := makeRequest(r, "GET", "/api/v1/stock-in/summary", token, nil) require.Equal(t, http.StatusOK, w.Code) s := parseResponse(w) assert.Equal(t, float64(2), s["month_count"]) assert.Equal(t, float64(400), s["month_amount"]) assert.Equal(t, float64(1), s["pending_count"]) // rolling30 口径:40 天前的单落「前一窗」,今天的 2 笔在近 30 天窗 makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{ "warehouse_id": wh.ID, "order_date": time.Now().AddDate(0, 0, -40).Format(time.RFC3339), "items": []map[string]interface{}{{"product_name": "B", "quantity": 1.0, "unit_price": 50.0}}, }) w = makeRequest(r, "GET", "/api/v1/stock-in/summary?window=rolling30", token, nil) require.Equal(t, http.StatusOK, w.Code) s = parseResponse(w) assert.Equal(t, float64(2), s["month_count"]) assert.Equal(t, float64(400), s["month_amount"]) assert.Equal(t, float64(1), s["last_month_count"]) assert.Equal(t, float64(50), s["last_month_amount"]) } func TestStockInHandler_DetailFilter(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SIDF") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") wh := testutil.CreateTestWarehouse(db, shop.ID, "W") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{ "warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{{"product_name": "BlueWine", "series": "Blue", "spec": "500ml", "quantity": 1.0, "unit_price": 10.0}}, }) makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{ "warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{{"product_name": "PlainWine", "series": "Plain", "spec": "480ml", "quantity": 1.0, "unit_price": 10.0}}, }) // 系列过滤(JOIN 明细) w := makeRequest(r, "GET", "/api/v1/stock-in/orders?series=Blue", token, nil) require.Equal(t, http.StatusOK, w.Code) assert.Equal(t, float64(1), parseResponse(w)["total"]) // 商品信息过滤(明细快照名 LIKE) w = makeRequest(r, "GET", "/api/v1/stock-in/orders?product=Plain", token, nil) assert.Equal(t, float64(1), parseResponse(w)["total"]) // 规格过滤 w = makeRequest(r, "GET", "/api/v1/stock-in/orders?spec=500ml", token, nil) assert.Equal(t, float64(1), parseResponse(w)["total"]) // 组合:系列 Blue + 规格 480ml → 无交集 w = makeRequest(r, "GET", "/api/v1/stock-in/orders?series=Blue&spec=480ml", token, nil) assert.Equal(t, float64(0), parseResponse(w)["total"]) // 商品编码过滤(明细 product_code LIKE):给 BlueWine 明细设已知码再按子串筛 db.Model(&model.StockInItem{}). Where("shop_id = ? AND product_name = ?", shop.ID, "BlueWine"). Update("product_code", "PC-XYZ") w = makeRequest(r, "GET", "/api/v1/stock-in/orders?product_code=XYZ", token, nil) assert.Equal(t, float64(1), parseResponse(w)["total"]) // 同一明细语义:一张单两条明细,系列在明细1、规格分属明细2。 makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{ "warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ {"product_name": "MixA", "series": "MixS", "spec": "111", "quantity": 1.0, "unit_price": 10.0}, {"product_name": "MixB", "series": "OtherS", "spec": "222", "quantity": 1.0, "unit_price": 10.0}, }, }) // 跨明细组合(系列在明细1、规格在明细2)→ 同一明细语义下无单命中 w = makeRequest(r, "GET", "/api/v1/stock-in/orders?series=MixS&spec=222", token, nil) assert.Equal(t, float64(0), parseResponse(w)["total"]) // 同一明细命中(系列 MixS + 规格 111 均在明细1)→ 1 w = makeRequest(r, "GET", "/api/v1/stock-in/orders?series=MixS&spec=111", token, nil) assert.Equal(t, float64(1), parseResponse(w)["total"]) // 退单状态过滤:把一单标 partial 再按 return_state 筛 var one model.StockInOrder db.Where("shop_id = ?", shop.ID).First(&one) db.Model(&model.StockInOrder{}).Where("id = ?", one.ID).Update("return_state", "partial") w = makeRequest(r, "GET", "/api/v1/stock-in/orders?return_state=partial", token, nil) assert.Equal(t, float64(1), parseResponse(w)["total"]) } func TestStockInHandler_Submit_WrongStatus(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI003") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product := testutil.CreateTestProduct(db, shop.ID, "Gin") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 创建 w := makeRequest(r, "POST", "/api/v1/stock-in/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-in/orders/%d/submit", orderID), token, nil) // 再次提交(应该失败,因为已经是 pending) w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", orderID), token, nil) assert.Equal(t, http.StatusBadRequest, w.Code) } func TestStockInHandler_Approve_NotPending(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI004") 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) // 创建但不提交(状态是 draft) w := makeRequest(r, "POST", "/api/v1/stock-in/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-in/orders/%d/approve", orderID), token, nil) assert.Equal(t, http.StatusBadRequest, w.Code) } func TestStockInHandler_Reject(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI005") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product := testutil.CreateTestProduct(db, shop.ID, "Tequila") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 创建并提交 w := makeRequest(r, "POST", "/api/v1/stock-in/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-in/orders/%d/submit", orderID), token, nil) // 驳回 w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/reject", orderID), token, nil) assert.Equal(t, http.StatusOK, w.Code) // 验证状态 w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-in/orders/%d", orderID), token, nil) detailData := parseResponse(w)["data"].(map[string]interface{}) assert.Equal(t, "rejected", detailData["status"]) } func TestStockInHandler_Withdraw(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI006") admin := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") operator := testutil.CreateTestUser(db, shop.ID, "op", "pass", "operator") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product := testutil.CreateTestProduct(db, shop.ID, "Tequila") adminToken := getAuthToken(admin.ID, shop.ID, "admin") opToken := getAuthToken(operator.ID, shop.ID, "operator") r := setupProtectedRouter(db) // 创建并提交(进入 pending) w := makeRequest(r, "POST", "/api/v1/stock-in/orders", adminToken, 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-in/orders/%d/submit", orderID), adminToken, nil) // 1. 操作员撤回「他人(管理员)」的单 → 403 w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/withdraw", orderID), opToken, nil) assert.Equal(t, http.StatusForbidden, w.Code) // 2. 管理员撤回任意单 → 200,状态回到 draft w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/withdraw", orderID), adminToken, nil) assert.Equal(t, http.StatusOK, w.Code) w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-in/orders/%d", orderID), adminToken, nil) assert.Equal(t, "draft", parseResponse(w)["data"].(map[string]interface{})["status"]) // 3. 撤回后已是 draft,再撤回 → 400(仅 pending 可撤回) w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/withdraw", orderID), adminToken, nil) assert.Equal(t, http.StatusBadRequest, w.Code) // 4. 撤回为 draft 后可再次修改并提交 w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", orderID), adminToken, nil) assert.Equal(t, http.StatusOK, w.Code) // 5. 操作员撤回「本人」提交的单 → 200(自己发的肯定能撤) w = makeRequest(r, "POST", "/api/v1/stock-in/orders", opToken, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ {"product_id": product.ID, "quantity": 3.0}, }, }) ownID := extractID(w) makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", ownID), opToken, nil) w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/withdraw", ownID), opToken, nil) assert.Equal(t, http.StatusOK, w.Code) w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-in/orders/%d", ownID), opToken, nil) assert.Equal(t, "draft", parseResponse(w)["data"].(map[string]interface{})["status"]) } func TestStockInHandler_GetNotFound(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI006") 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-in/orders/99999", token, nil) assert.Equal(t, http.StatusNotFound, w.Code) } func TestStockInHandler_TotalAmount(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI007") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product1 := testutil.CreateTestProduct(db, shop.ID, "ProductA") product2 := testutil.CreateTestProduct(db, shop.ID, "ProductB") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) w := makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), // 故意发旧 key unit_price:v1.0.87 及之前客户端的兼容回归(回落到 cost_price) "items": []map[string]interface{}{ {"product_id": product1.ID, "quantity": 10.0, "unit_price": 5.0}, // 50 {"product_id": product2.ID, "quantity": 3.0, "unit_price": 20.0}, // 60 }, }) require.Equal(t, http.StatusCreated, w.Code) data := parseResponse(w)["data"].(map[string]interface{}) assert.Equal(t, float64(110), data["cost_total"]) } // 回归:编辑草稿入库单后 total_amount 必须按新明细重算(此前 Update 漏了累加,编辑后被清成 0)。 func TestStockInHandler_UpdateRecomputesTotal(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI_UPD") 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) // 建草稿:1 行 10×5 = 50 w := makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ {"product_name": "茅台", "series": "飞天", "spec": "500ml", "quantity": 10.0, "unit_price": 5.0}, }, }) require.Equal(t, http.StatusCreated, w.Code) orderID := extractID(w) // 编辑:改成 2 行 12×3100 + 1×4500 = 41700 w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d", orderID), token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ {"product_name": "茅台2009", "series": "大件", "spec": "500ml", "quantity": 12.0, "cost_price": 3100.0}, {"product_name": "茅台十五年", "series": "大件", "spec": "500ml", "quantity": 1.0, "cost_price": 4500.0}, }, }) require.Equal(t, http.StatusOK, w.Code) // 取详情核对 total_amount 已重算(不是 0) w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-in/orders/%d", orderID), token, nil) require.Equal(t, http.StatusOK, w.Code) data := parseResponse(w)["data"].(map[string]interface{}) assert.Equal(t, float64(41700), data["cost_total"], "编辑后金额应按新明细重算") } func TestStockInHandler_NoAuth(t *testing.T) { db := testutil.SetupTestDB() r := setupProtectedRouter(db) w := makeRequest(r, "GET", "/api/v1/stock-in/orders", "", nil) assert.Equal(t, http.StatusUnauthorized, w.Code) w = makeRequest(r, "POST", "/api/v1/stock-in/orders", "", map[string]interface{}{ "warehouse_id": 1, }) assert.Equal(t, http.StatusUnauthorized, w.Code) } func TestStockInHandler_TenantIsolation(t *testing.T) { db := testutil.SetupTestDB() shopA := testutil.CreateTestShop(db, "SI_A") userA := testutil.CreateTestUser(db, shopA.ID, "adminA", "pass", "admin") warehouseA := testutil.CreateTestWarehouse(db, shopA.ID, "WA") productA := testutil.CreateTestProduct(db, shopA.ID, "BeerA") tokenA := getAuthToken(userA.ID, shopA.ID, "admin") shopB := testutil.CreateTestShop(db, "SI_B") userB := testutil.CreateTestUser(db, shopB.ID, "adminB", "pass", "admin") tokenB := getAuthToken(userB.ID, shopB.ID, "admin") r := setupProtectedRouter(db) // 门店 A 创建入库单 w := makeRequest(r, "POST", "/api/v1/stock-in/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-in/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-in/orders/%d", orderAID), tokenB, nil) assert.Equal(t, http.StatusNotFound, w.Code) // 门店 B 不能提交门店 A 的订单 w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", orderAID), tokenB, nil) assert.Equal(t, http.StatusBadRequest, w.Code) // 门店 B 不能审核门店 A 的订单 w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/approve", orderAID), tokenB, nil) assert.Equal(t, http.StatusBadRequest, w.Code) } func TestStockInHandler_Create_MissingWarehouse(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI008") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 缺少 warehouse_id,应该返回 400 w := makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{ "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{}, }) assert.Equal(t, http.StatusBadRequest, w.Code) } func TestStockInHandler_Reject_NotPending(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI009") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product := testutil.CreateTestProduct(db, shop.ID, "Whiskey") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 创建但不提交(draft 状态) w := makeRequest(r, "POST", "/api/v1/stock-in/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-in/orders/%d/reject", orderID), token, nil) assert.Equal(t, http.StatusBadRequest, w.Code) } func TestStockInHandler_List_FilterByKeyword(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI011") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product := testutil.CreateTestProduct(db, shop.ID, "Brandy") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 往来单位:茅台供应商 partner := &model.Partner{Code: "P001", Name: "茅台供应商", Type: "supplier"} partner.ShopID = shop.ID require.NoError(t, db.Create(partner).Error) // 单据1:挂往来单位 w := makeRequest(r, "POST", "/api/v1/stock-in/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": 5.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-in/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": 3.0}}, }) require.Equal(t, http.StatusCreated, w.Code) // 往来单位名不再进入 keyword 范围(供应商/客户改由工具栏下拉筛选)→ 搜不到 // (商品是 Brandy,不含「茅台」) w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=茅台", token, nil) assert.Equal(t, float64(0), parseResponse(w)["total"].(float64)) // 按单号搜索 → 只命中单据1 w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword="+order1No, token, nil) assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) // 无关键词 → 两条都在 w = makeRequest(r, "GET", "/api/v1/stock-in/orders", token, nil) assert.Equal(t, float64(2), parseResponse(w)["total"].(float64)) } // keyword 匹配主数据编码 p.code:明细快照编码为空时仍可按编码搜到(与出库同款双匹配) func TestStockInHandler_List_KeywordMatchesMasterCode(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI_MCODE") 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) product := &model.Product{ TenantBase: model.TenantBase{ShopID: shop.ID}, Code: "ZXZ030001", Name: "山崎12年", } require.NoError(t, db.Create(product).Error) order := &model.StockInOrder{ TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: "RK-MCODE-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.StockInItem{ OrderID: order.ID, ShopID: shop.ID, ProductID: product.ID, Quantity: 1.0, }).Error) w := makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=ZXZ030001", token, nil) assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=ZXZ999999", token, nil) assert.Equal(t, float64(0), parseResponse(w)["total"].(float64)) } func TestStockInHandler_List_FilterByProductName(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI012") 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) // 单据1:明细酒名「飞天茅台」(入库按 product_name 新建独立 product) w := makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{{"product_name": "飞天茅台", "quantity": 5.0}}, }) require.Equal(t, http.StatusCreated, w.Code) // 单据2:明细酒名「拉菲红酒」 w = makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{{"product_name": "拉菲红酒", "quantity": 3.0}}, }) require.Equal(t, http.StatusCreated, w.Code) // 按酒名模糊反查 → 各命中 1 单 w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=茅台", token, nil) assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=红酒", token, nil) assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) // 无关键词 → 两单都在 w = makeRequest(r, "GET", "/api/v1/stock-in/orders", token, nil) assert.Equal(t, float64(2), parseResponse(w)["total"].(float64)) } // 回归:历史导入单的明细 product_id 统一指向占位商品「历史导入占位」(p.name 非空), // 真实酒名只在快照列 product_name。修复前搜索用 COALESCE(NULLIF(p.name,”), product_name) // 被占位名劫持,导入单按真实酒名搜不到;修复后「p.name OR product_name」任一命中。 func TestStockInHandler_List_FilterByImportedSnapshotName(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI012B") 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) // 占位商品:名「历史导入占位」,编码 HIST-PLACEHOLDER(模拟 import-history) placeholder := &model.Product{ TenantBase: model.TenantBase{ShopID: shop.ID}, Name: "历史导入占位", Code: "HIST-PLACEHOLDER", } require.NoError(t, db.Create(placeholder).Error) // 导入单:明细 product_id 指向占位商品,真实酒名「飞天茅台」仅在快照 product_name order := &model.StockInOrder{ TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: "RK-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.StockInItem{ OrderID: order.ID, ShopID: shop.ID, ProductID: placeholder.ID, ProductCode: "ZXZ000010", ProductName: "飞天茅台", Quantity: 5.0, }).Error) // 按真实酒名搜 → 命中导入单(修复前为 0) w := makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=茅台", token, nil) assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) } func TestStockInHandler_List_FilterByProductPinyin(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI013") 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) // 入库按 product_name 新建独立 product,会自动写入拼音列(name_pinyin/name_initials) w := makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{{"product_name": "飞天茅台", "quantity": 5.0}}, }) require.Equal(t, http.StatusCreated, w.Code) // 全拼命中(feitianmaotai) w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=feitian", token, nil) assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) // 首字母命中(ftmt) w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=ftmt", token, nil) assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) // 不相关拼音不命中 w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=wuliangye", token, nil) assert.Equal(t, float64(0), parseResponse(w)["total"].(float64)) } func TestStockInHandler_List_FilterByProductName_TenantIsolation(t *testing.T) { db := testutil.SetupTestDB() r := setupProtectedRouter(db) // 店A、店B 各建一张含「茅台」明细的入库单 shopA := testutil.CreateTestShop(db, "SI014A") userA := testutil.CreateTestUser(db, shopA.ID, "adminA", "pass", "admin") whA := testutil.CreateTestWarehouse(db, shopA.ID, "WA") tokenA := getAuthToken(userA.ID, shopA.ID, "admin") shopB := testutil.CreateTestShop(db, "SI014B") userB := testutil.CreateTestUser(db, shopB.ID, "adminB", "pass", "admin") whB := testutil.CreateTestWarehouse(db, shopB.ID, "WB") tokenB := getAuthToken(userB.ID, shopB.ID, "admin") for _, tc := range []struct { token string wh uint64 }{{tokenA, whA.ID}, {tokenB, whB.ID}} { w := makeRequest(r, "POST", "/api/v1/stock-in/orders", tc.token, map[string]interface{}{ "warehouse_id": tc.wh, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{{"product_name": "飞天茅台", "quantity": 1.0}}, }) require.Equal(t, http.StatusCreated, w.Code) } // 店A 搜茅台只看到自己的 1 单,绝不串到店B w := makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=茅台", tokenA, nil) assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=茅台", tokenB, nil) assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) } func TestStockInHandler_List_FilterByStatus(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI010") 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) // 创建一个 draft 和一个 pending 订单 w := makeRequest(r, "POST", "/api/v1/stock-in/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}}, }) draftOrderID := extractID(w) w = makeRequest(r, "POST", "/api/v1/stock-in/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": 3.0}}, }) pendingOrderID := extractID(w) makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", pendingOrderID), token, nil) _ = draftOrderID // 过滤 pending 状态 w = makeRequest(r, "GET", "/api/v1/stock-in/orders?status=pending", token, nil) resp := parseResponse(w) assert.Equal(t, float64(1), resp["total"].(float64)) // 过滤 draft 状态 w = makeRequest(r, "GET", "/api/v1/stock-in/orders?status=draft", token, nil) resp = parseResponse(w) assert.Equal(t, float64(1), resp["total"].(float64)) // 逗号分隔的多状态:pending + draft 应返回两条(审核标签页用法) w = makeRequest(r, "GET", "/api/v1/stock-in/orders?status=pending,draft", token, nil) resp = parseResponse(w) assert.Equal(t, float64(2), resp["total"].(float64)) } // SEC-001 回归:跨租户外键引用必须被拒绝;存量脏引用(他店 partner_id) // 读取时 Preload 不得把他店对象带出。 func TestStockInHandler_CrossTenantRefs(t *testing.T) { db := testutil.SetupTestDB() shopA := testutil.CreateTestShop(db, "SEC1A") shopB := testutil.CreateTestShop(db, "SEC1B") userA := testutil.CreateTestUser(db, shopA.ID, "admin", "pass", "admin") whA := testutil.CreateTestWarehouse(db, shopA.ID, "WA") whB := testutil.CreateTestWarehouse(db, shopB.ID, "WB") partnerB := &model.Partner{ TenantBase: model.TenantBase{ShopID: shopB.ID}, Name: "B店机密供应商", Type: "supplier", Phone: "13800000000"} require.NoError(t, db.Create(partnerB).Error) tokenA := getAuthToken(userA.ID, shopA.ID, "admin") r := setupProtectedRouter(db) // 1) 引用他店仓库 → 400 w := makeRequest(r, "POST", "/api/v1/stock-in/orders", tokenA, map[string]interface{}{ "warehouse_id": whB.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{{"product_name": "A", "quantity": 1.0, "unit_price": 10.0}}, }) require.Equal(t, http.StatusBadRequest, w.Code, "cross-shop warehouse must be rejected") // 2) 引用他店往来单位 → 400 w = makeRequest(r, "POST", "/api/v1/stock-in/orders", tokenA, map[string]interface{}{ "warehouse_id": whA.ID, "partner_id": partnerB.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{{"product_name": "A", "quantity": 1.0, "unit_price": 10.0}}, }) require.Equal(t, http.StatusBadRequest, w.Code, "cross-shop partner must be rejected") // 3) 存量脏引用:直接落库一张 A 店单指向 B 店 partner,GET 不得带出 B 店对象 dirty := &model.StockInOrder{ TenantBase: model.TenantBase{ShopID: shopA.ID}, WarehouseID: whA.ID, PartnerID: &partnerB.ID, OrderNo: "SEC1-DIRTY", Status: "draft", OperatorID: userA.ID, } require.NoError(t, db.Create(dirty).Error) w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-in/orders/%d", dirty.ID), tokenA, nil) require.Equal(t, http.StatusOK, w.Code) body := w.Body.String() assert.NotContains(t, body, "B店机密供应商", "cross-shop partner PII must not leak via preload") assert.NotContains(t, body, "13800000000") }