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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 库存
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user