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)