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
+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)