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)
|
||||
|
||||
Reference in New Issue
Block a user