feat(backend): 出入库搜索重构 + 商品编码搜索 + KPI/确认售价/详细搜索

- 搜索 keyword 命中改为 单号/酒名(名+拼音)/商品编码,去掉往来单位匹配
- applyStockOrderDetailFilters 合并明细过滤为单条子查询(同一明细语义)+ 新增 product_code
- 新增出入库 KPI summary、出库确认售价(confirm-sale) 及路由
- 补充/更新对应单元测试

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-02 08:47:44 +08:00
parent 2c2bb7ccb3
commit 998bbab546
7 changed files with 421 additions and 21 deletions
+106 -4
View File
@@ -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)
+91 -2
View File
@@ -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)
+64 -11
View File
@@ -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)
+90 -4
View File
@@ -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)
}
@@ -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")
+4
View File
@@ -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)
}
// 库存
+63
View File
@@ -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 {