From 998bbab546ee2b051469e281dad1203f24f6fedd Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 2 Jul 2026 08:47:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=E5=87=BA=E5=85=A5=E5=BA=93?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E9=87=8D=E6=9E=84=20+=20=E5=95=86=E5=93=81?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E6=90=9C=E7=B4=A2=20+=20KPI/=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E5=94=AE=E4=BB=B7/=E8=AF=A6=E7=BB=86=E6=90=9C?= =?UTF-8?q?=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 搜索 keyword 命中改为 单号/酒名(名+拼音)/商品编码,去掉往来单位匹配 - applyStockOrderDetailFilters 合并明细过滤为单条子查询(同一明细语义)+ 新增 product_code - 新增出入库 KPI summary、出库确认售价(confirm-sale) 及路由 - 补充/更新对应单元测试 Co-Authored-By: Claude Opus 4.8 --- backend/internal/handler/stock_in.go | 110 +++++++++++++++++++- backend/internal/handler/stock_in_test.go | 93 ++++++++++++++++- backend/internal/handler/stock_out.go | 75 +++++++++++-- backend/internal/handler/stock_out_test.go | 94 ++++++++++++++++- backend/internal/handler/testhelper_test.go | 3 + backend/internal/router/router.go | 4 + backend/internal/service/stock.go | 63 +++++++++++ 7 files changed, 421 insertions(+), 21 deletions(-) diff --git a/backend/internal/handler/stock_in.go b/backend/internal/handler/stock_in.go index 2ad897d..ed97ac3 100644 --- a/backend/internal/handler/stock_in.go +++ b/backend/internal/handler/stock_in.go @@ -52,21 +52,23 @@ func (h *StockInHandler) List(c *gin.Context) { } if kw := strings.TrimSpace(c.Query("keyword")); kw != "" { like := "%" + kw + "%" - // keyword 同时命中:单号 / 往来单位 / 酒名(含全拼+首字母,反查含该酒明细的单据)。 + // keyword 命中:单号 / 酒名(含全拼+首字母)/ 商品编码 —— 反查含该明细的单据。 + // (不含往来单位;供应商/客户改由工具栏下拉筛选。) // 酒名「product 主数据名 OR 明细快照名」任一命中——不能用 COALESCE 二选一:历史导入单的 // 明细 product_id 统一指向占位商品「历史导入占位」(p.name 非空),COALESCE 会被占位名劫持, // 永远取不到真实快照 product_name,导致导入单按真实酒名搜不到。拼音只在 products 上(快照无拼音列)。 query = query.Where( "order_no LIKE ?"+ - " OR partner_id IN (SELECT id FROM partners WHERE shop_id = ? AND name LIKE ?)"+ " OR id IN (SELECT it.order_id FROM stock_in_items it"+ " LEFT JOIN products p ON p.id = it.product_id AND p.shop_id = it.shop_id"+ " WHERE it.shop_id = ? AND it.deleted_at IS NULL"+ - " AND (p.name LIKE ? OR it.product_name LIKE ?"+ + " AND (p.name LIKE ? OR it.product_name LIKE ? OR it.product_code LIKE ?"+ " OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?))", - like, shopID, like, shopID, like, like, like, like, + like, shopID, like, like, like, like, like, ) } + // 详细搜索多字段(各参数独立可选、组合为 AND) + query = applyStockOrderDetailFilters(query, shopID, c, "stock_in_items") var total int64 query.Count(&total) @@ -79,6 +81,106 @@ func (h *StockInHandler) List(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": orders, "total": total, "page": page, "page_size": pageSize}) } +// applyStockOrderDetailFilters 详细搜索多字段过滤(出入库共用,itemsTable 传 stock_in_items / stock_out_items, +// 为硬编码常量非用户输入,无注入风险)。各参数独立可选:单据表字段直接过滤,明细字段走子查询 IN。 +func applyStockOrderDetailFilters(q *gorm.DB, shopID uint64, c *gin.Context, itemsTable string) *gorm.DB { + if v := strings.TrimSpace(c.Query("order_no")); v != "" { + q = q.Where("order_no LIKE ?", "%"+v+"%") + } + if v := c.Query("partner_id"); v != "" { + q = q.Where("partner_id = ?", v) + } + if v := c.Query("operator_id"); v != "" { + q = q.Where("operator_id = ?", v) + } + if v := c.Query("reviewer_id"); v != "" { + q = q.Where("reviewer_id = ?", v) + } + // 退单状态(none/partial/full;单据表直接过滤,供「部分退单/已退单」筛选) + if v := c.Query("return_state"); v != "" { + q = q.Where("return_state = ?", v) + } + // 明细/商品级条件合并为「一条」子查询:命中要求**同一条明细**同时满足全部条件 + // (product/编码/系列/规格/批次/分类),而非「各条件各自由不同明细凑齐」。 + // 一次扫描 items(走 idx_order_id 回连单据),语义更准、比多条 id IN 更省。 + var itemConds []string + var itemArgs []interface{} + if v := strings.TrimSpace(c.Query("product")); v != "" { + like := "%" + v + "%" + itemConds = append(itemConds, + "(p.name LIKE ? OR it.product_name LIKE ? OR it.product_code LIKE ?"+ + " OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?)") + itemArgs = append(itemArgs, like, like, like, like, like) + } + if v := strings.TrimSpace(c.Query("product_code")); v != "" { + itemConds = append(itemConds, "it.product_code LIKE ?") + itemArgs = append(itemArgs, "%"+v+"%") + } + if v := strings.TrimSpace(c.Query("series")); v != "" { + itemConds = append(itemConds, "it.series = ?") + itemArgs = append(itemArgs, v) + } + if v := strings.TrimSpace(c.Query("spec")); v != "" { + itemConds = append(itemConds, "it.spec = ?") + itemArgs = append(itemArgs, v) + } + if v := strings.TrimSpace(c.Query("batch")); v != "" { + itemConds = append(itemConds, "it.batch_no LIKE ?") + itemArgs = append(itemArgs, "%"+v+"%") + } + if v := c.Query("category_id"); v != "" { + itemConds = append(itemConds, "p.category_id = ?") + itemArgs = append(itemArgs, v) + } + if len(itemConds) > 0 { + sub := "id IN (SELECT it.order_id FROM " + itemsTable + " it" + + " LEFT JOIN products p ON p.id = it.product_id AND p.shop_id = it.shop_id" + + " WHERE it.shop_id = ? AND it.deleted_at IS NULL AND " + + strings.Join(itemConds, " AND ") + ")" + args := append([]interface{}{shopID}, itemArgs...) + q = q.Where(sub, args...) + } + return q +} + +// stockSummary 出/入库 KPI 汇总(本月 + 上月同期供环比 + 待审核)。 +type stockSummary struct { + MonthCount int64 `json:"month_count"` + MonthAmount float64 `json:"month_amount"` + PendingCount int64 `json:"pending_count"` + LastMonthCount int64 `json:"last_month_count"` + LastMonthAmount float64 `json:"last_month_amount"` +} + +// monthBounds 本月起 / 下月起 / 上月起(YYYY-MM-DD 串,日期串比较跨 MySQL/SQLite 可移植)。 +func monthBounds(now time.Time) (mStart, next, prev string) { + s := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + const f = "2006-01-02" + return s.Format(f), s.AddDate(0, 1, 0).Format(f), s.AddDate(0, -1, 0).Format(f) +} + +// Summary GET /api/v1/stock-in/summary —— 全店入库 KPI(守多租户) +func (h *StockInHandler) Summary(c *gin.Context) { + shopID := middleware.GetShopID(c) + mStart, next, prev := monthBounds(time.Now()) + agg := func(from, to string) (int64, float64) { + var r struct { + Cnt int64 + Amt float64 + } + h.db.Model(&model.StockInOrder{}). + Where("shop_id = ? AND deleted_at IS NULL AND order_date >= ? AND order_date < ?", shopID, from, to). + Select("COUNT(*) AS cnt, COALESCE(SUM(total_amount),0) AS amt").Scan(&r) + return r.Cnt, r.Amt + } + var s stockSummary + s.MonthCount, s.MonthAmount = agg(mStart, next) + s.LastMonthCount, s.LastMonthAmount = agg(prev, mStart) + h.db.Model(&model.StockInOrder{}). + Where("shop_id = ? AND status = ? AND deleted_at IS NULL", shopID, "pending").Count(&s.PendingCount) + c.JSON(http.StatusOK, s) +} + // Get GET /api/v1/stock-in/orders/:id func (h *StockInHandler) Get(c *gin.Context) { shopID := middleware.GetShopID(c) diff --git a/backend/internal/handler/stock_in_test.go b/backend/internal/handler/stock_in_test.go index ecf012b..9692b59 100644 --- a/backend/internal/handler/stock_in_test.go +++ b/backend/internal/handler/stock_in_test.go @@ -101,6 +101,94 @@ func TestStockInHandler_List(t *testing.T) { 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"]) +} + +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") @@ -438,9 +526,10 @@ func TestStockInHandler_List_FilterByKeyword(t *testing.T) { }) require.Equal(t, http.StatusCreated, w.Code) - // 按往来单位名搜索 → 只命中单据1 + // 往来单位名不再进入 keyword 范围(供应商/客户改由工具栏下拉筛选)→ 搜不到 + // (商品是 Brandy,不含「茅台」) w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=茅台", token, nil) - assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) + assert.Equal(t, float64(0), parseResponse(w)["total"].(float64)) // 按单号搜索 → 只命中单据1 w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword="+order1No, token, nil) diff --git a/backend/internal/handler/stock_out.go b/backend/internal/handler/stock_out.go index ad2a84f..69b9755 100644 --- a/backend/internal/handler/stock_out.go +++ b/backend/internal/handler/stock_out.go @@ -5,6 +5,7 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -45,28 +46,23 @@ func (h *StockOutHandler) List(c *gin.Context) { } if kw := strings.TrimSpace(c.Query("keyword")); kw != "" { like := "%" + kw + "%" - // keyword 同时命中:单号 / 往来单位 / 酒名(含全拼+首字母,反查含该酒明细的单据)。 + // keyword 命中:单号 / 酒名(含全拼+首字母)/ 商品编码 —— 反查含该明细的单据。 + // (不含往来单位;供应商/客户改由工具栏下拉筛选。) // 酒名「product 主数据名 OR 明细快照名」任一命中——不能用 COALESCE 二选一:历史导入单的 // 明细 product_id 统一指向占位商品「历史导入占位」(p.name 非空),COALESCE 会被占位名劫持, // 永远取不到真实快照 product_name,导致导入单按真实酒名搜不到。拼音只在 products 上(快照无拼音列)。 query = query.Where( "order_no LIKE ?"+ - " OR partner_id IN (SELECT id FROM partners WHERE shop_id = ? AND name LIKE ?)"+ " OR id IN (SELECT it.order_id FROM stock_out_items it"+ " LEFT JOIN products p ON p.id = it.product_id AND p.shop_id = it.shop_id"+ " WHERE it.shop_id = ? AND it.deleted_at IS NULL"+ - " AND (p.name LIKE ? OR it.product_name LIKE ?"+ + " AND (p.name LIKE ? OR it.product_name LIKE ? OR it.product_code LIKE ?"+ " OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?))", - like, shopID, like, shopID, like, like, like, like, - ) - } - // 按商品编码反查单据:精确匹配,走 idx_so_items_shop_code(shop_id, product_code) 索引 - if code := strings.TrimSpace(c.Query("product_code")); code != "" { - query = query.Where( - "id IN (SELECT order_id FROM stock_out_items WHERE shop_id = ? AND product_code = ? AND deleted_at IS NULL)", - shopID, code, + like, shopID, like, like, like, like, like, ) } + // 详细搜索多字段(各参数独立可选、组合为 AND) + query = applyStockOrderDetailFilters(query, shopID, c, "stock_out_items") var total int64 query.Count(&total) @@ -79,6 +75,63 @@ func (h *StockOutHandler) List(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": orders, "total": total, "page": page, "page_size": pageSize}) } +// Summary GET /api/v1/stock-out/summary —— 全店出库 KPI(守多租户) +func (h *StockOutHandler) Summary(c *gin.Context) { + shopID := middleware.GetShopID(c) + mStart, next, prev := monthBounds(time.Now()) + agg := func(from, to string) (int64, float64) { + var r struct { + Cnt int64 + Amt float64 + } + h.db.Model(&model.StockOutOrder{}). + Where("shop_id = ? AND deleted_at IS NULL AND order_date >= ? AND order_date < ?", shopID, from, to). + Select("COUNT(*) AS cnt, COALESCE(SUM(total_amount),0) AS amt").Scan(&r) + return r.Cnt, r.Amt + } + var s stockSummary + s.MonthCount, s.MonthAmount = agg(mStart, next) + s.LastMonthCount, s.LastMonthAmount = agg(prev, mStart) + h.db.Model(&model.StockOutOrder{}). + Where("shop_id = ? AND status = ? AND deleted_at IS NULL", shopID, "pending").Count(&s.PendingCount) + c.JSON(http.StatusOK, s) +} + +// ConfirmSale PUT/POST /api/v1/stock-out/orders/:id/confirm-sale —— 确认售价(先出后定价) +func (h *StockOutHandler) ConfirmSale(c *gin.Context) { + shopID := middleware.GetShopID(c) + userID := middleware.GetUserID(c) + role := middleware.GetRole(c) + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + + var req struct { + Items []service.SaleConfirmItem `json:"items"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if len(req.Items) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "未提供要确认的明细"}) + return + } + for _, it := range req.Items { + if it.SalePrice <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "售价必须大于 0"}) + return + } + } + if err := h.stockSvc.ConfirmStockOutSale(shopID, id, userID, role, req.Items); err != nil { + if errors.Is(err, service.ErrForbidden) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权确认售价"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "sale confirmed"}) +} + // Get GET /api/v1/stock-out/orders/:id func (h *StockOutHandler) Get(c *gin.Context) { shopID := middleware.GetShopID(c) diff --git a/backend/internal/handler/stock_out_test.go b/backend/internal/handler/stock_out_test.go index 9a0aa78..7643340 100644 --- a/backend/internal/handler/stock_out_test.go +++ b/backend/internal/handler/stock_out_test.go @@ -156,14 +156,19 @@ func TestStockOutHandler_List_FilterByProductCode(t *testing.T) { }, }) - // 精确命中 1 单 + // 命中 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;前缀不算精确(不命中) + // 商品编码改为子串 LIKE(详细搜索友好):子串 ZXZ → 命中 ZXZ-FIND 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(1), parseResponse(w)["total"].(float64)) + + // 不存在的编码 → 0 + w = makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=NOPE", token, nil) + require.Equal(t, http.StatusOK, w.Code) assert.Equal(t, float64(0), parseResponse(w)["total"].(float64)) } @@ -199,9 +204,13 @@ func TestStockOutHandler_List_FilterByKeyword(t *testing.T) { }) require.Equal(t, http.StatusCreated, w.Code) - // 按往来单位名搜索 + // 往来单位名不再进入 keyword 范围(供应商/客户改由工具栏下拉筛选)→ 搜不到 w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=海底捞", token, nil) - assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) + assert.Equal(t, float64(0), parseResponse(w)["total"].(float64)) + + // 按酒名搜索(两单都含 Beer) + w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=Beer", token, nil) + assert.Equal(t, float64(2), parseResponse(w)["total"].(float64)) // 按单号搜索 w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword="+order1No, token, nil) @@ -460,3 +469,80 @@ func TestStockOutHandler_InventoryLog_OnApprove(t *testing.T) { assert.Equal(t, float64(100), logs[0].QtyBefore) assert.Equal(t, float64(80), logs[0].QtyAfter) } + +func TestStockOutHandler_Summary(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SOSUM") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + wh := testutil.CreateTestWarehouse(db, shop.ID, "W") + product := testutil.CreateTestProduct(db, shop.ID, "Wine") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &product.ID, Quantity: 100}) + + for i := 0; i < 2; i++ { + makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ + "warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339), + "items": []map[string]interface{}{{"product_id": product.ID, "quantity": 1.0, "unit_price": 5.0, "sale_price": 100.0}}, + }) + } + var first model.StockOutOrder + db.Where("shop_id = ?", shop.ID).Order("id ASC").First(&first) + makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", first.ID), token, nil) + + w := makeRequest(r, "GET", "/api/v1/stock-out/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(200), s["month_amount"]) // 2 单 × (1×100 售价) + assert.Equal(t, float64(1), s["pending_count"]) +} + +func TestStockOutHandler_ConfirmSale(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SOCS") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + wh := testutil.CreateTestWarehouse(db, shop.ID, "W") + 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: &wh.ID, ProductID: &product.ID, Quantity: 100}) + + // 待定价售价出库单(不传 sale_price → 0) + w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ + "warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339), + "items": []map[string]interface{}{{"product_id": product.ID, "quantity": 10.0, "unit_price": 5.0}}, + }) + require.Equal(t, http.StatusCreated, w.Code) + 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 item model.StockOutItem + db.Where("order_id = ?", orderID).First(&item) + assert.Equal(t, float64(0), item.SalePrice) + + // 确认售价 8 + w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-out/orders/%d/confirm-sale", orderID), token, map[string]interface{}{ + "items": []map[string]interface{}{{"item_id": item.ID, "sale_price": 8.0}}, + }) + require.Equal(t, http.StatusOK, w.Code) + + var got model.StockOutItem + db.First(&got, item.ID) + assert.Equal(t, float64(8), got.SalePrice) + var order model.StockOutOrder + db.First(&order, orderID) + assert.Equal(t, float64(80), order.TotalAmount) // 8 × 10 + var fr model.FinanceRecord + require.NoError(t, db.Where("shop_id = ? AND ref_type = ?", shop.ID, "stock_out_sale_adjust").First(&fr).Error) + assert.Equal(t, float64(80), fr.Amount) + + // 操作员无权确认售价 + op := testutil.CreateTestUser(db, shop.ID, "op1", "pass", "operator") + opToken := getAuthToken(op.ID, shop.ID, "operator") + w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-out/orders/%d/confirm-sale", orderID), opToken, map[string]interface{}{ + "items": []map[string]interface{}{{"item_id": item.ID, "sale_price": 9.0}}, + }) + assert.Equal(t, http.StatusForbidden, w.Code) +} diff --git a/backend/internal/handler/testhelper_test.go b/backend/internal/handler/testhelper_test.go index 1363b5b..57e0dee 100644 --- a/backend/internal/handler/testhelper_test.go +++ b/backend/internal/handler/testhelper_test.go @@ -59,6 +59,7 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine { // 入库路由 stockIn := api.Group("/stock-in") stockIn.GET("/orders", stockInH.List) + stockIn.GET("/summary", stockInH.Summary) stockIn.GET("/orders/:id", stockInH.Get) stockIn.POST("/orders", stockInH.Create) stockIn.PUT("/orders/:id", stockInH.Update) @@ -72,6 +73,7 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine { // 出库路由 stockOut := api.Group("/stock-out") stockOut.GET("/orders", stockOutH.List) + stockOut.GET("/summary", stockOutH.Summary) stockOut.GET("/orders/:id", stockOutH.Get) stockOut.POST("/orders", stockOutH.Create) stockOut.PUT("/orders/:id", stockOutH.Update) @@ -80,6 +82,7 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine { stockOut.PUT("/orders/:id/reject", stockOutH.Reject) stockOut.PUT("/orders/:id/withdraw", stockOutH.Withdraw) stockOut.POST("/orders/:id/return", stockOutH.Return) + stockOut.POST("/orders/:id/confirm-sale", stockOutH.ConfirmSale) // 库存路由 inv := api.Group("/inventory") diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index f200c1c..785a08c 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -147,6 +147,7 @@ func Setup(r *gin.Engine, db *gorm.DB) { stockIn := api.Group("/stock-in") { stockIn.GET("/orders", stockInH.List) + stockIn.GET("/summary", stockInH.Summary) stockIn.GET("/orders/:id", stockInH.Get) stockIn.POST("/orders", stockInH.Create) stockIn.PUT("/orders/:id", stockInH.Update) @@ -166,6 +167,7 @@ func Setup(r *gin.Engine, db *gorm.DB) { stockOut := api.Group("/stock-out") { stockOut.GET("/orders", stockOutH.List) + stockOut.GET("/summary", stockOutH.Summary) stockOut.GET("/orders/:id", stockOutH.Get) stockOut.POST("/orders", stockOutH.Create) stockOut.PUT("/orders/:id", stockOutH.Update) @@ -177,6 +179,8 @@ func Setup(r *gin.Engine, db *gorm.DB) { stockOut.PUT("/orders/:id/withdraw", stockOutH.Withdraw) // 退单(已审核单退货,库存加回+冲应收):管理员/超管 或 本人(service 内判权) stockOut.POST("/orders/:id/return", stockOutH.Return) + // 确认售价(先出后定价,重算应收):仅管理员/超管(service 内判权) + stockOut.POST("/orders/:id/confirm-sale", stockOutH.ConfirmSale) } // 库存 diff --git a/backend/internal/service/stock.go b/backend/internal/service/stock.go index 5f48770..64d3b81 100644 --- a/backend/internal/service/stock.go +++ b/backend/internal/service/stock.go @@ -163,6 +163,12 @@ type CostConfirmItem struct { UnitPrice float64 `json:"unit_price"` } +// SaleConfirmItem 出库「确认售价」的一条明细(先出后定价)。 +type SaleConfirmItem struct { + ItemID uint64 `json:"item_id"` + SalePrice float64 `json:"sale_price"` +} + // ConfirmStockInCost 「确认进价」:调货等场景以 0 价(暂估)入库并已审核/已出库后, // 价格确定时补填真实进价。不反审核、不撤销任何已发生的动作,而是在一个事务内 // 前向写补偿: @@ -262,6 +268,63 @@ func (s *StockService) ConfirmStockInCost(shopID, orderID, userID uint64, role s }) } +// ConfirmStockOutSale 出库「先出后定价」:对 sale_price<=0 的明细写真实售价, +// 重算应收(total_amount=Σ售价×数量)并补应收差额流水。仅 admin/superadmin、已审核单可确认。 +func (s *StockService) ConfirmStockOutSale(shopID, orderID, userID uint64, role string, items []SaleConfirmItem) error { + if role != "admin" && role != "superadmin" { + return ErrForbidden + } + return s.db.Transaction(func(tx *gorm.DB) error { + var order model.StockOutOrder + if err := tx.Preload("Items").Where("id = ? AND shop_id = ?", orderID, shopID). + First(&order).Error; err != nil { + return err + } + if order.Status != "approved" { + return errors.New("只有已审核单据可确认售价") + } + + want := make(map[uint64]float64, len(items)) + for _, it := range items { + want[it.ItemID] = it.SalePrice + } + + now := time.Now() + var totalDiff float64 + for i := range order.Items { + it := &order.Items[i] + newPrice, ok := want[it.ID] + if !ok || newPrice == it.SalePrice { + continue + } + diff := (newPrice - it.SalePrice) * it.Quantity + totalDiff += diff + if err := tx.Model(it).Update("sale_price", newPrice).Error; err != nil { + return err + } + } + + if totalDiff == 0 { + return nil + } + + // 应收总额按差额重算 + if err := tx.Model(&order). + Update("total_amount", gorm.Expr("total_amount + ?", totalDiff)).Error; err != nil { + return err + } + + // 应收差额调整流水(滚动余额,与出库审核建应收同口径 last+amount) + bal := partnerLastBalance(tx, shopID, order.PartnerID) + totalDiff + oid := order.ID + return tx.Create(&model.FinanceRecord{ + ShopID: shopID, PartnerID: order.PartnerID, Type: "receivable", + Amount: totalDiff, Balance: bal, Status: "open", + RefType: "stock_out_sale_adjust", RefID: &oid, OperatorID: userID, RecordDate: now, + }).Error + }) +} + // ApproveStockOut 审核出库单,FIFO 扣减批次库存 func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error { return s.db.Transaction(func(tx *gorm.DB) error {