diff --git a/backend/internal/handler/finance.go b/backend/internal/handler/finance.go index 107564a..0a8814a 100644 --- a/backend/internal/handler/finance.go +++ b/backend/internal/handler/finance.go @@ -2,6 +2,7 @@ package handler import ( "net/http" + "strconv" "time" "github.com/gin-gonic/gin" @@ -25,10 +26,13 @@ func (h *FinanceHandler) ListRecords(c *gin.Context) { shopID := middleware.GetShopID(c) var q struct { - Type string `form:"type"` - Month string `form:"month"` // e.g. "2026-04" - Page int `form:"page,default=1"` - PageSize int `form:"page_size,default=50"` + Type string `form:"type"` + Month string `form:"month"` // e.g. "2026-04" + StartDate string `form:"start_date"` // YYYY-MM-DD(区间过滤,优先于 month) + EndDate string `form:"end_date"` + PartnerID uint64 `form:"partner_id"` + Page int `form:"page,default=1"` + PageSize int `form:"page_size,default=50"` } if err := c.ShouldBindQuery(&q); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -43,7 +47,18 @@ func (h *FinanceHandler) ListRecords(c *gin.Context) { if q.Type != "" { base = base.Where("type = ?", q.Type) } - if q.Month != "" { + if q.PartnerID != 0 { + base = base.Where("partner_id = ?", q.PartnerID) + } + // 日期过滤:区间(本季/自定义)优先;否则按单月。日期串比较跨 MySQL/SQLite 可移植。 + if q.StartDate != "" || q.EndDate != "" { + if q.StartDate != "" { + base = base.Where("record_date >= ?", q.StartDate) + } + if q.EndDate != "" { + base = base.Where("record_date <= ?", q.EndDate) + } + } else if q.Month != "" { base = base.Where("DATE_FORMAT(record_date, '%Y-%m') = ?", q.Month) } @@ -161,6 +176,47 @@ func (h *FinanceHandler) CloseByRef(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) } +// Trend GET /api/v1/finance/trend?months=6 — 近 N 个自然月收支趋势 +// in=当月出库(销售)额、out=当月入库(采购)额;与 stock Summary 同口径 +// (按 order_date、不筛状态、deleted_at IS NULL),日期串比较跨 MySQL/SQLite 可移植。 +func (h *FinanceHandler) Trend(c *gin.Context) { + shopID := middleware.GetShopID(c) + months := 6 + if v := c.Query("months"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 24 { + months = n + } + } + + now := time.Now() + first := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + type point struct { + Month string `json:"month"` + In float64 `json:"in"` + Out float64 `json:"out"` + } + sum := func(table, from, to string) float64 { + var v float64 + h.db.Table(table). + Where("shop_id = ? AND deleted_at IS NULL AND order_date >= ? AND order_date < ?", + shopID, from, to). + Select("COALESCE(SUM(total_amount),0)").Scan(&v) + return v + } + const f = "2006-01-02" + points := make([]point, 0, months) + for i := months - 1; i >= 0; i-- { + mStart := first.AddDate(0, -i, 0) + mEnd := mStart.AddDate(0, 1, 0) + points = append(points, point{ + Month: mStart.Format("2006-01"), + In: sum("stock_out_orders", mStart.Format(f), mEnd.Format(f)), + Out: sum("stock_in_orders", mStart.Format(f), mEnd.Format(f)), + }) + } + c.JSON(http.StatusOK, gin.H{"data": points}) +} + // Summary GET /api/v1/finance/summary — 按往来单位汇总未结清 func (h *FinanceHandler) Summary(c *gin.Context) { shopID := middleware.GetShopID(c) diff --git a/backend/internal/handler/finance_test.go b/backend/internal/handler/finance_test.go new file mode 100644 index 0000000..4141c4f --- /dev/null +++ b/backend/internal/handler/finance_test.go @@ -0,0 +1,113 @@ +package handler + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/testutil" +) + +// Trend:近 N 月按 order_date 聚合出库(in)/入库(out)金额,多租户隔离 +func TestFinanceHandler_Trend(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "FIN001") + other := testutil.CreateTestShop(db, "FIN001B") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Main") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + + now := time.Now() + thisMonth := time.Date(now.Year(), now.Month(), 5, 0, 0, 0, 0, time.UTC) + lastMonth := thisMonth.AddDate(0, -1, 0) + + mkOut := func(shopID uint64, date time.Time, amount float64) { + require.NoError(t, db.Create(&model.StockOutOrder{ + TenantBase: model.TenantBase{ShopID: shopID}, + OrderNo: fmt.Sprintf("CK-%d-%d", shopID, time.Now().UnixNano()), + WarehouseID: warehouse.ID, + OperatorID: user.ID, + Status: "approved", + OrderDate: model.Date{Time: date}, + TotalAmount: amount, + }).Error) + } + mkIn := func(shopID uint64, date time.Time, amount float64) { + require.NoError(t, db.Create(&model.StockInOrder{ + TenantBase: model.TenantBase{ShopID: shopID}, + OrderNo: fmt.Sprintf("RK-%d-%d", shopID, time.Now().UnixNano()), + WarehouseID: warehouse.ID, + OperatorID: user.ID, + Status: "approved", + OrderDate: model.Date{Time: date}, + TotalAmount: amount, + }).Error) + } + mkOut(shop.ID, thisMonth, 1000) + mkOut(shop.ID, thisMonth, 860) + mkOut(shop.ID, lastMonth, 500) + mkIn(shop.ID, thisMonth, 700) + mkOut(other.ID, thisMonth, 99999) // 别店数据不得串店 + + w := makeRequest(r, "GET", "/api/v1/finance/trend?months=2", token, nil) + require.Equal(t, http.StatusOK, w.Code) + data := parseResponse(w)["data"].([]interface{}) + require.Len(t, data, 2) + + prev := data[0].(map[string]interface{}) + cur := data[1].(map[string]interface{}) + assert.Equal(t, lastMonth.Format("2006-01"), prev["month"]) + assert.Equal(t, float64(500), prev["in"]) + assert.Equal(t, float64(0), prev["out"]) + assert.Equal(t, thisMonth.Format("2006-01"), cur["month"]) + assert.Equal(t, float64(1860), cur["in"]) + assert.Equal(t, float64(700), cur["out"]) +} + +// ListRecords 日期区间:start_date/end_date 过滤 record_date(区间优先于 month) +func TestFinanceHandler_ListRecords_DateRange(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "FIN002") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + + mk := func(date string, amount float64) { + d, err := time.Parse("2006-01-02", date) + require.NoError(t, err) + require.NoError(t, db.Create(&model.FinanceRecord{ + ShopID: shop.ID, + Type: "receipt", + Amount: amount, + Status: "closed", + OperatorID: user.ID, + RecordDate: d, + }).Error) + } + mk("2026-04-10", 100) + mk("2026-05-15", 200) + mk("2026-06-20", 300) + + // 区间 [2026-05-01, 2026-06-30] → 命中 2 条 + w := makeRequest(r, "GET", + "/api/v1/finance/records?start_date=2026-05-01&end_date=2026-06-30", token, nil) + require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, float64(2), parseResponse(w)["total"].(float64)) + + // 只给 start → 下界过滤 + w = makeRequest(r, "GET", + "/api/v1/finance/records?start_date=2026-06-01", token, nil) + assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) + + // 区间优先于 month(month 会被忽略) + w = makeRequest(r, "GET", + "/api/v1/finance/records?start_date=2026-04-01&end_date=2026-04-30&month=2026-06", + token, nil) + assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) +} diff --git a/backend/internal/handler/inventory.go b/backend/internal/handler/inventory.go index a56bbf9..3472ed2 100644 --- a/backend/internal/handler/inventory.go +++ b/backend/internal/handler/inventory.go @@ -77,6 +77,11 @@ func (h *InventoryHandler) List(c *gin.Context) { like := "%" + keyword + "%" args = append(args, like, like, like, like) } + // 商品编码独立筛选(库存屏工具栏第二个搜索框,对齐原型 codeInput) + if code := c.Query("code"); code != "" { + baseWhere += " AND COALESCE(NULLIF(p.code,''), NULLIF(sii.product_code,''), inv.product_code, '') LIKE ?" + args = append(args, "%"+code+"%") + } if warehouseIDStr != "" { baseWhere += " AND inv.warehouse_id = ?" args = append(args, warehouseIDStr) @@ -167,24 +172,41 @@ type inventorySummary struct { InStockQty float64 `json:"in_stock_qty"` // 在库总数量 ShortageCount int64 `json:"shortage_count"` // 缺货数(qty<=0) WarningCount int64 `json:"warning_count"` // 预警数(0 0 AND p.min_stock IS NOT NULL AND inv.quantity < p.min_stock THEN 1 ELSE 0 END), 0) AS warning_count + COALESCE(SUM(CASE WHEN inv.quantity > 0 AND p.min_stock IS NOT NULL AND inv.quantity < p.min_stock THEN 1 ELSE 0 END), 0) AS warning_count, + COALESCE(SUM(CASE WHEN inv.created_at < ? THEN 1 ELSE 0 END), 0) AS last_month_sku, + COALESCE(SUM(CASE WHEN inv.created_at < ? THEN inv.quantity - COALESCE(lg.net, 0) ELSE 0 END), 0) AS last_month_qty, + COALESCE(SUM(CASE WHEN inv.created_at < ? THEN (inv.quantity - COALESCE(lg.net, 0)) * COALESCE(sii.unit_price, inv.unit_price, p.purchase_price) ELSE 0 END), 0) AS last_month_value FROM inventories inv LEFT JOIN stock_in_items sii ON sii.id = inv.stock_in_item_id LEFT JOIN products p ON p.id = inv.product_id AND p.deleted_at IS NULL + LEFT JOIN ( + SELECT product_id, warehouse_id, + SUM(CASE WHEN direction = 'in' THEN quantity ELSE -quantity END) AS net + FROM inventory_logs + WHERE shop_id = ? AND created_at >= ? + GROUP BY product_id, warehouse_id + ) lg ON lg.product_id = inv.product_id AND lg.warehouse_id = inv.warehouse_id WHERE inv.shop_id = ? AND inv.deleted_at IS NULL` var s inventorySummary - if err := h.db.Raw(sql, shopID).Scan(&s).Error; err != nil { + if err := h.db.Raw(sql, monthStart, monthStart, monthStart, shopID, monthStart, shopID).Scan(&s).Error; err != nil { util.RespondError(c, http.StatusInternalServerError, "QUERY_ERROR", "查询失败") return } diff --git a/backend/internal/handler/inventory_test.go b/backend/internal/handler/inventory_test.go index 932e894..1d15adf 100644 --- a/backend/internal/handler/inventory_test.go +++ b/backend/internal/handler/inventory_test.go @@ -287,6 +287,79 @@ func TestInventoryHandler_List_DeletedProductFallsBackToSnapshot(t *testing.T) { assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) } +// 商品编码独立筛选(?code=,库存屏第二个搜索框) +func TestInventoryHandler_List_FilterByCode(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "INV_CODE") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") + product1 := testutil.CreateTestProduct(db, shop.ID, "Beer1") // code=P-Beer1 + product2 := testutil.CreateTestProduct(db, shop.ID, "Wine2") // code=P-Wine2 + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + + db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product1.ID, Quantity: 10}) + db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product2.ID, Quantity: 20}) + + // 精确前缀命中一条 + w := makeRequest(r, "GET", "/api/v1/inventory?code=P-Beer1", token, nil) + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) + + // 模糊命中两条 + w = makeRequest(r, "GET", "/api/v1/inventory?code=P-", token, nil) + assert.Equal(t, float64(2), parseResponse(w)["total"].(float64)) + + // 无命中 + w = makeRequest(r, "GET", "/api/v1/inventory?code=NOPE", token, nil) + assert.Equal(t, float64(0), parseResponse(w)["total"].(float64)) +} + +// Summary 月初快照字段:月初数量 = 当前数量 − 本月净流水 +func TestInventoryHandler_Summary_LastMonth(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "INV_SUM") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") + product1 := testutil.CreateTestProduct(db, shop.ID, "Old") + product2 := testutil.CreateTestProduct(db, shop.ID, "New") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + + now := time.Now() + monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + price1 := 100.0 + + // inv1:上月已存在(月初数量 = 30 − (25−5) = 10) + inv1 := model.Inventory{ + ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product1.ID, + Quantity: 30, UnitPrice: &price1, + CreatedAt: monthStart.AddDate(0, 0, -10), + } + require.NoError(t, db.Create(&inv1).Error) + // 本月流水:入 25、出 5 → 净 +20 + db.Create(&model.InventoryLog{ShopID: shop.ID, WarehouseID: warehouse.ID, ProductID: product1.ID, Direction: "in", Quantity: 25}) + db.Create(&model.InventoryLog{ShopID: shop.ID, WarehouseID: warehouse.ID, ProductID: product1.ID, Direction: "out", Quantity: 5}) + + // inv2:本月新入库,不计入月初快照 + price2 := 10.0 + db.Create(&model.Inventory{ + ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product2.ID, + Quantity: 5, UnitPrice: &price2, + }) + db.Create(&model.InventoryLog{ShopID: shop.ID, WarehouseID: warehouse.ID, ProductID: product2.ID, Direction: "in", Quantity: 5}) + + w := makeRequest(r, "GET", "/api/v1/inventory/summary", token, nil) + assert.Equal(t, http.StatusOK, w.Code) + resp := parseResponse(w) + assert.Equal(t, float64(2), resp["sku_count"].(float64)) + assert.Equal(t, float64(35), resp["in_stock_qty"].(float64)) + assert.Equal(t, float64(30*100+5*10), resp["stock_value"].(float64)) + assert.Equal(t, float64(1), resp["last_month_sku"].(float64)) + assert.Equal(t, float64(10), resp["last_month_qty"].(float64)) + assert.Equal(t, float64(10*100), resp["last_month_value"].(float64)) +} + func TestInventoryHandler_AfterStockInApprove(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "INV008") diff --git a/backend/internal/handler/partner.go b/backend/internal/handler/partner.go index 7aec0ad..7f1a0c6 100644 --- a/backend/internal/handler/partner.go +++ b/backend/internal/handler/partner.go @@ -36,7 +36,11 @@ func (h *PartnerHandler) List(c *gin.Context) { query = query.Where("FIND_IN_SET(?, type)", t) } if kw := c.Query("keyword"); kw != "" { - query = query.Where("name LIKE ? OR phone LIKE ?", "%"+kw+"%", "%"+kw+"%") + // 原型搜索口径:名称 / 拼音(全拼+首字母)/ 联系人;phone 保留兼容 + like := "%" + kw + "%" + query = query.Where( + "name LIKE ? OR contact LIKE ? OR phone LIKE ? OR name_pinyin LIKE ? OR name_initials LIKE ?", + like, like, like, like, like) } var total int64 @@ -56,6 +60,7 @@ func (h *PartnerHandler) Create(c *gin.Context) { return } p.ShopID = shopID + p.NamePinyin, p.NameInitials = util.ToPinyin(p.Name) // 编码未提供时自动生成:供应商 S001/S002…,客户 C001/C002… if p.Code == "" { prefix := "S" @@ -92,25 +97,34 @@ func (h *PartnerHandler) Update(c *gin.Context) { Address string `json:"address"` BankAccount string `json:"bank_account"` CreditLimit float64 `json:"credit_limit"` - Status string `json:"status"` - Remark string `json:"remark"` + // 期初余额:指针区分「未传」与 0,旧客户端不传时不清零 + Balance *float64 `json:"balance"` + Status string `json:"status"` + Remark string `json:"remark"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := h.db.Model(&p).Where("shop_id = ?", shopID).Updates(map[string]interface{}{ - "name": req.Name, - "type": req.Type, - "code": req.Code, - "contact": req.Contact, - "phone": req.Phone, - "address": req.Address, - "bank_account": req.BankAccount, - "credit_limit": req.CreditLimit, - "status": req.Status, - "remark": req.Remark, - }).Error; err != nil { + pinyin, initials := util.ToPinyin(req.Name) + updates := map[string]interface{}{ + "name": req.Name, + "name_pinyin": pinyin, + "name_initials": initials, + "type": req.Type, + "code": req.Code, + "contact": req.Contact, + "phone": req.Phone, + "address": req.Address, + "bank_account": req.BankAccount, + "credit_limit": req.CreditLimit, + "status": req.Status, + "remark": req.Remark, + } + if req.Balance != nil { + updates["balance"] = *req.Balance + } + if err := h.db.Model(&p).Where("shop_id = ?", shopID).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/backend/internal/handler/partner_test.go b/backend/internal/handler/partner_test.go index 663283c..882545b 100644 --- a/backend/internal/handler/partner_test.go +++ b/backend/internal/handler/partner_test.go @@ -205,6 +205,33 @@ func TestPartnerHandler_List_KeywordSearch(t *testing.T) { assert.Equal(t, float64(0), resp["total"].(float64)) } +// 拼音/联系人搜索(原型口径:名称 / 拼音 / 联系人):Create 时自动生成拼音列 +func TestPartnerHandler_List_PinyinAndContactSearch(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "PT006B") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + + makeRequest(r, "POST", "/api/v1/partners", token, map[string]interface{}{ + "name": "茅台华东总代", "type": "supplier", "contact": "张伟", + }) + makeRequest(r, "POST", "/api/v1/partners", token, map[string]interface{}{ + "name": "鼎丰超市", "type": "customer", "contact": "王芳", + }) + + for kw, want := range map[string]float64{ + "maotai": 1, // 全拼 + "mthdzd": 1, // 首字母 + "张伟": 1, // 联系人 + "dingfeng": 1, + "buxiang": 0, + } { + w := makeRequest(r, "GET", "/api/v1/partners?keyword="+kw, token, nil) + assert.Equal(t, want, parseResponse(w)["total"].(float64), "keyword=%s", kw) + } +} + // 下拉需一次取全部:请求大页(>默认 20)应返回全部,而非被 ValidatePageSize // 回退到默认 20(超上限即返回默认值的历史坑)。 func TestPartnerHandler_List_LargePageSizeReturnsAll(t *testing.T) { diff --git a/backend/internal/handler/product.go b/backend/internal/handler/product.go index dbb73d4..48e74d2 100644 --- a/backend/internal/handler/product.go +++ b/backend/internal/handler/product.go @@ -39,8 +39,11 @@ func (h *ProductHandler) List(c *gin.Context) { Where("shop_id = ? AND deleted_at IS NULL", shopID) if keyword != "" { - query = query.Where("name LIKE ? OR code LIKE ? OR barcode LIKE ?", - "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + // 原型搜索口径:商品名 / 拼音(全拼+首字母)/ 编码;barcode 保留兼容 + like := "%" + keyword + "%" + query = query.Where( + "name LIKE ? OR code LIKE ? OR barcode LIKE ? OR name_pinyin LIKE ? OR name_initials LIKE ?", + like, like, like, like, like) } if categoryID != "" { query = query.Where("category_id = ?", categoryID) diff --git a/backend/internal/handler/product_test.go b/backend/internal/handler/product_test.go index 8cb9508..73a62af 100644 --- a/backend/internal/handler/product_test.go +++ b/backend/internal/handler/product_test.go @@ -66,6 +66,30 @@ func TestProductHandler_CRUD(t *testing.T) { assert.Equal(t, float64(0), resp["total"].(float64)) } +// 拼音搜索(原型口径:商品名 / 拼音 / 编码):List keyword 匹配 name_pinyin/name_initials +func TestProductHandler_List_PinyinSearch(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "PROD_PY") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + + w := makeRequest(r, "POST", "/api/v1/products", token, map[string]interface{}{ + "name": "飞天茅台", "code": "MT-FT-500", "unit": "件", + }) + require.Equal(t, http.StatusCreated, w.Code) + + for kw, want := range map[string]float64{ + "feitian": 1, // 全拼 + "ftmt": 1, // 首字母 + "MT-FT": 1, // 编码 + "wuliang": 0, + } { + w := makeRequest(r, "GET", "/api/v1/products?keyword="+kw, token, nil) + assert.Equal(t, want, parseResponse(w)["total"].(float64), "keyword=%s", kw) + } +} + func TestProductHandler_HotelIsolation(t *testing.T) { db := testutil.SetupTestDB() diff --git a/backend/internal/handler/stock_in.go b/backend/internal/handler/stock_in.go index 2b90035..50ca3fa 100644 --- a/backend/internal/handler/stock_in.go +++ b/backend/internal/handler/stock_in.go @@ -57,14 +57,15 @@ func (h *StockInHandler) List(c *gin.Context) { // 酒名「product 主数据名 OR 明细快照名」任一命中——不能用 COALESCE 二选一:历史导入单的 // 明细 product_id 统一指向占位商品「历史导入占位」(p.name 非空),COALESCE 会被占位名劫持, // 永远取不到真实快照 product_name,导致导入单按真实酒名搜不到。拼音只在 products 上(快照无拼音列)。 + // 编码同理「主数据 p.code OR 快照 it.product_code」双匹配,与页面显示口径(主数据优先)对齐。 query = query.Where( "order_no 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 ? OR it.product_code LIKE ?"+ - " OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?))", - like, shopID, like, like, like, like, like, + " OR p.code LIKE ? OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?))", + like, shopID, like, like, like, like, like, like, ) } // 详细搜索多字段(各参数独立可选、组合为 AND) @@ -109,12 +110,14 @@ func applyStockOrderDetailFilters(q *gorm.DB, shopID uint64, c *gin.Context, ite 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) + " OR p.code LIKE ? OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?)") + itemArgs = append(itemArgs, like, like, like, like, like, like) } + // 编码「主数据 OR 快照」双匹配:存量出库明细快照有空串,只查快照会漏单 if v := strings.TrimSpace(c.Query("product_code")); v != "" { - itemConds = append(itemConds, "it.product_code LIKE ?") - itemArgs = append(itemArgs, "%"+v+"%") + like := "%" + v + "%" + itemConds = append(itemConds, "(it.product_code LIKE ? OR p.code LIKE ?)") + itemArgs = append(itemArgs, like, like) } if v := strings.TrimSpace(c.Query("series")); v != "" { itemConds = append(itemConds, "it.series = ?") diff --git a/backend/internal/handler/stock_in_test.go b/backend/internal/handler/stock_in_test.go index 8f34f5e..e80fbed 100644 --- a/backend/internal/handler/stock_in_test.go +++ b/backend/internal/handler/stock_in_test.go @@ -578,6 +578,45 @@ func TestStockInHandler_List_FilterByKeyword(t *testing.T) { assert.Equal(t, float64(2), parseResponse(w)["total"].(float64)) } +// keyword 匹配主数据编码 p.code:明细快照编码为空时仍可按编码搜到(与出库同款双匹配) +func TestStockInHandler_List_KeywordMatchesMasterCode(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SI_MCODE") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + + product := &model.Product{ + TenantBase: model.TenantBase{ShopID: shop.ID}, + Code: "ZXZ030001", + Name: "山崎12年", + } + require.NoError(t, db.Create(product).Error) + + order := &model.StockInOrder{ + TenantBase: model.TenantBase{ShopID: shop.ID}, + OrderNo: "RK-MCODE-001", + WarehouseID: warehouse.ID, + OperatorID: user.ID, + Status: "approved", + OrderDate: model.Date{Time: time.Now()}, + } + require.NoError(t, db.Create(order).Error) + require.NoError(t, db.Create(&model.StockInItem{ + OrderID: order.ID, + ShopID: shop.ID, + ProductID: product.ID, + Quantity: 1.0, + }).Error) + + w := makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=ZXZ030001", token, nil) + assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) + + w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=ZXZ999999", token, nil) + assert.Equal(t, float64(0), parseResponse(w)["total"].(float64)) +} + func TestStockInHandler_List_FilterByProductName(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI012") diff --git a/backend/internal/handler/stock_out.go b/backend/internal/handler/stock_out.go index 69b9755..d9bad6c 100644 --- a/backend/internal/handler/stock_out.go +++ b/backend/internal/handler/stock_out.go @@ -2,6 +2,7 @@ package handler import ( "errors" + "fmt" "net/http" "strconv" "strings" @@ -51,14 +52,16 @@ func (h *StockOutHandler) List(c *gin.Context) { // 酒名「product 主数据名 OR 明细快照名」任一命中——不能用 COALESCE 二选一:历史导入单的 // 明细 product_id 统一指向占位商品「历史导入占位」(p.name 非空),COALESCE 会被占位名劫持, // 永远取不到真实快照 product_name,导致导入单按真实酒名搜不到。拼音只在 products 上(快照无拼音列)。 + // 编码同理「主数据 p.code OR 快照 it.product_code」双匹配:存量应用建单的明细快照为空串, + // 只查快照会「页面看得到编码、搜索搜不到」。 query = query.Where( "order_no 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 ? OR it.product_code LIKE ?"+ - " OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?))", - like, shopID, like, like, like, like, like, + " OR p.code LIKE ? OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?))", + like, shopID, like, like, like, like, like, like, ) } // 详细搜索多字段(各参数独立可选、组合为 AND) @@ -147,6 +150,44 @@ func (h *StockOutHandler) Get(c *gin.Context) { // Create POST /api/v1/stock-out/orders +// fillStockOutItemSnapshots 按 product_id 从商品主数据拷明细快照列(编码/名称/系列/规格/批次/生产日期)。 +// 明细 = product 引用 + 快照(历史保真:商品日后改名/删除,单据仍能还原当时信息;搜索/退单提示读快照)。 +// 入库建单在 createIndependentProduct 后即填快照,出库同样必须在建单/改单时填齐。 +func fillStockOutItemSnapshots(db *gorm.DB, shopID uint64, items []model.StockOutItem) error { + if len(items) == 0 { + return nil + } + ids := make([]uint64, 0, len(items)) + for i := range items { + ids = append(ids, items[i].ProductID) + } + var prods []model.Product + if err := db.Where("shop_id = ? AND id IN ?", shopID, ids).Find(&prods).Error; err != nil { + return err + } + byID := make(map[uint64]*model.Product, len(prods)) + for i := range prods { + byID[prods[i].ID] = &prods[i] + } + for i := range items { + p, ok := byID[items[i].ProductID] + if !ok { + return fmt.Errorf("明细第 %d 行商品不存在", i+1) + } + items[i].ProductCode = p.Code + items[i].ProductName = p.Name + items[i].Series = p.Series + items[i].Spec = p.Spec + if items[i].BatchNo == "" { + items[i].BatchNo = p.BatchNo + } + if items[i].ProductionDate == nil { + items[i].ProductionDate = p.ProductionDate + } + } + return nil +} + func (h *StockOutHandler) Create(c *gin.Context) { shopID := middleware.GetShopID(c) operatorID := middleware.GetUserID(c) @@ -186,6 +227,11 @@ func (h *StockOutHandler) Create(c *gin.Context) { } req.TotalAmount = total + if err := fillStockOutItemSnapshots(h.db, shopID, req.Items); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := h.db.Create(&req).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -222,6 +268,9 @@ func (h *StockOutHandler) Update(c *gin.Context) { req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice total += req.Items[i].Quantity * req.Items[i].SalePrice } + if err := fillStockOutItemSnapshots(tx, shopID, req.Items); err != nil { + return err + } updates := map[string]interface{}{ "warehouse_id": req.WarehouseID, "partner_id": req.PartnerID, diff --git a/backend/internal/handler/stock_out_test.go b/backend/internal/handler/stock_out_test.go index 7643340..c49cbd0 100644 --- a/backend/internal/handler/stock_out_test.go +++ b/backend/internal/handler/stock_out_test.go @@ -133,45 +133,151 @@ func TestStockOutHandler_List(t *testing.T) { assert.Equal(t, float64(2), resp["total"].(float64)) } -// 按商品编码精确反查出库单(明细 product_code = ?) +// 按商品编码反查出库单:快照以主数据为准(建单回填),客户端传的 product_code 被覆盖 func TestStockOutHandler_List_FilterByProductCode(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SO_CODE") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") - product := testutil.CreateTestProduct(db, shop.ID, "Beer") + find := testutil.CreateTestProduct(db, shop.ID, "ZXZFind") // code=P-ZXZFind + other := testutil.CreateTestProduct(db, shop.ID, "OtherOne") // code=P-OtherOne token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) + // 单1 不传 product_code(真实客户端行为);单2 传假编码,应被主数据覆盖 makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ - {"product_id": product.ID, "product_code": "ZXZ-FIND", "quantity": 1.0}, + {"product_id": find.ID, "quantity": 1.0}, }, }) makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ - {"product_id": product.ID, "product_code": "OTHER-CODE", "quantity": 1.0}, + {"product_id": other.ID, "product_code": "FAKE-CODE", "quantity": 1.0}, }, }) - // 命中 1 单 - w := makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=ZXZ-FIND", token, nil) + // 按主数据编码命中 1 单 + w := makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=P-ZXZFind", token, nil) require.Equal(t, http.StatusOK, w.Code) assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) - // 商品编码改为子串 LIKE(详细搜索友好):子串 ZXZ → 命中 ZXZ-FIND + // 商品编码子串 LIKE(详细搜索友好):子串 ZXZ → 命中 P-ZXZFind 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)) + // 客户端传的假编码已被主数据覆盖 → 搜不到 + w = makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=FAKE-CODE", token, nil) + require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, float64(0), 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)) } +// 建单回填快照:出库明细的编码/名称/系列/规格/批次/生产日期从商品主数据拷入 +// (修复:此前 Create/Update 不填快照 → 新建单快照全空,按编码搜不到、退单提示空名) +func TestStockOutHandler_Create_FillsItemSnapshots(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SO_SNAP") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + + prodDate := model.Date{Time: time.Date(2024, 7, 11, 0, 0, 0, 0, time.UTC)} + product := &model.Product{ + TenantBase: model.TenantBase{ShopID: shop.ID}, + Code: "ZXZ020659", + Name: "百富威士忌", + Series: "12年", + Spec: "700ml", + BatchNo: "PZ240711", + ProductionDate: &prodDate, + } + require.NoError(t, db.Create(product).Error) + + w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ + "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), + "items": []map[string]interface{}{ + {"product_id": product.ID, "quantity": 1.0}, + }, + }) + require.Equal(t, http.StatusCreated, w.Code) + + var item model.StockOutItem + require.NoError(t, db.Where("shop_id = ? AND product_id = ?", shop.ID, product.ID).First(&item).Error) + assert.Equal(t, "ZXZ020659", item.ProductCode) + assert.Equal(t, "百富威士忌", item.ProductName) + assert.Equal(t, "12年", item.Series) + assert.Equal(t, "700ml", item.Spec) + assert.Equal(t, "PZ240711", item.BatchNo) + require.NotNil(t, item.ProductionDate) + assert.Equal(t, "2024-07-11", item.ProductionDate.Time.Format("2006-01-02")) + + // 引用不存在商品 → 400 + w = makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ + "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), + "items": []map[string]interface{}{ + {"product_id": 99999, "quantity": 1.0}, + }, + }) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// 存量单(快照为空、product_id 指向真实商品)按主数据编码可搜: +// keyword 与详细搜索都走「p.code OR it.product_code」双匹配(页面能看到的就能搜到) +func TestStockOutHandler_List_SearchByMasterCodeWithEmptySnapshot(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SO_MCODE") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + + product := &model.Product{ + TenantBase: model.TenantBase{ShopID: shop.ID}, + Code: "ZXZ020659", + Name: "百富威士忌", + } + require.NoError(t, db.Create(product).Error) + + // 直造存量形态:明细快照全空串(修复前应用建单即如此) + order := &model.StockOutOrder{ + TenantBase: model.TenantBase{ShopID: shop.ID}, + OrderNo: "CK20260623000008", + WarehouseID: warehouse.ID, + OperatorID: user.ID, + Status: "approved", + OrderDate: model.Date{Time: time.Now()}, + } + require.NoError(t, db.Create(order).Error) + require.NoError(t, db.Create(&model.StockOutItem{ + OrderID: order.ID, + ShopID: shop.ID, + ProductID: product.ID, + Quantity: 1.0, + }).Error) + + // keyword 按主数据编码 → 命中(修复前为 0) + w := makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=ZXZ020659", token, nil) + assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) + + // 详细搜索 product_code / product 参数同样命中 + w = makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=ZXZ020659", token, nil) + assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) + w = makeRequest(r, "GET", "/api/v1/stock-out/orders?product=ZXZ020659", token, nil) + assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) + + // 别的编码搜不到 + w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=ZXZ999999", token, nil) + assert.Equal(t, float64(0), parseResponse(w)["total"].(float64)) +} + func TestStockOutHandler_List_FilterByKeyword(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SO011") diff --git a/backend/internal/handler/testhelper_test.go b/backend/internal/handler/testhelper_test.go index 57e0dee..06d976c 100644 --- a/backend/internal/handler/testhelper_test.go +++ b/backend/internal/handler/testhelper_test.go @@ -87,10 +87,20 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine { // 库存路由 inv := api.Group("/inventory") inv.GET("", inventoryH.List) + inv.GET("/summary", inventoryH.Summary) inv.GET("/logs", inventoryH.Logs) inv.POST("/checks", inventoryH.CreateCheck) inv.GET("/checks/:id", inventoryH.GetCheck) + // 财务路由 + financeH := NewFinanceHandler(db) + finance := api.Group("/finance") + finance.GET("/records", financeH.ListRecords) + finance.POST("/records", financeH.Create) + finance.PUT("/records/:id/close", financeH.Close) + finance.GET("/summary", financeH.Summary) + finance.GET("/trend", financeH.Trend) + // 许可证路由 license := api.Group("/license") license.POST("/activate", licenseH.Activate) diff --git a/backend/internal/model/partner.go b/backend/internal/model/partner.go index cbfaf8a..fc7fdd6 100644 --- a/backend/internal/model/partner.go +++ b/backend/internal/model/partner.go @@ -4,6 +4,9 @@ type Partner struct { TenantBase Code string `gorm:"size:50" json:"code"` Name string `gorm:"size:200;not null" json:"name" binding:"required"` + // 拼音搜索列(同 products):Create/Update 时由 util.ToPinyin 生成,启动回填存量 + NamePinyin string `gorm:"size:400;index" json:"-"` + NameInitials string `gorm:"size:100;index" json:"-"` Type string `gorm:"type:set('supplier','customer');default:'supplier'" json:"type"` Contact string `gorm:"size:50" json:"contact"` Phone string `gorm:"size:30" json:"phone"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 785a08c..1adf40f 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -213,6 +213,7 @@ func Setup(r *gin.Engine, db *gorm.DB) { finance.PUT("/records/:id/close", financeH.Close) finance.PUT("/records/close-by-ref", financeH.CloseByRef) finance.GET("/summary", financeH.Summary) + finance.GET("/trend", financeH.Trend) } // 酒行信息 diff --git a/backend/main.go b/backend/main.go index 4e9a7a1..b7c5168 100644 --- a/backend/main.go +++ b/backend/main.go @@ -13,6 +13,7 @@ import ( "github.com/wangjia/jiu/backend/internal/model" "github.com/wangjia/jiu/backend/internal/router" "github.com/wangjia/jiu/backend/internal/service" + "github.com/wangjia/jiu/backend/internal/util" ) func main() { @@ -35,6 +36,9 @@ func main() { // 自动迁移(GORM AutoMigrate 只增不删,生产安全) autoMigrate(db) + // 回填存量往来单位的拼音搜索列(幂等,只处理空值行) + backfillPartnerPinyin(db) + // 启动会话/失败登录保留期清理任务(后台 goroutine) service.StartSessionCleanup(db, config.C.Session.RetentionDays) @@ -153,3 +157,23 @@ func autoMigrate(db *gorm.DB) { } log.Println("AutoMigrate completed") } + +// backfillPartnerPinyin 为存量往来单位生成拼音搜索列(name_pinyin 为空的行)。 +// 与 products 的拼音列同机制:写入时自动生成,这里兜底历史数据。 +func backfillPartnerPinyin(db *gorm.DB) { + var partners []model.Partner + db.Where("(name_pinyin = '' OR name_pinyin IS NULL) AND deleted_at IS NULL").Find(&partners) + filled := 0 + for i := range partners { + py, ini := util.ToPinyin(partners[i].Name) + if py == "" && ini == "" { + continue + } + db.Model(&model.Partner{}).Where("id = ?", partners[i].ID). + Updates(map[string]interface{}{"name_pinyin": py, "name_initials": ini}) + filled++ + } + if filled > 0 { + log.Printf("backfill partner pinyin: %d rows", filled) + } +} diff --git a/backend/schema/schema.sql b/backend/schema/schema.sql index ab574b7..c020d9b 100644 --- a/backend/schema/schema.sql +++ b/backend/schema/schema.sql @@ -246,6 +246,8 @@ CREATE TABLE IF NOT EXISTS `partners` ( `shop_id` BIGINT UNSIGNED NOT NULL, `code` VARCHAR(50) DEFAULT NULL, `name` VARCHAR(200) NOT NULL, + `name_pinyin` VARCHAR(400) DEFAULT NULL COMMENT '名称全拼(搜索用,写入时自动生成)', + `name_initials` VARCHAR(100) DEFAULT NULL COMMENT '名称拼音首字母(搜索用)', `type` SET('supplier','customer') NOT NULL DEFAULT 'supplier' COMMENT '可同时是供应商和客户', `contact` VARCHAR(50) DEFAULT NULL COMMENT '联系人', `phone` VARCHAR(30) DEFAULT NULL, diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go index e72182e..1902638 100644 --- a/backend/testutil/setup.go +++ b/backend/testutil/setup.go @@ -299,6 +299,8 @@ func SetupTestDB() *gorm.DB { shop_id INTEGER NOT NULL, code TEXT, name TEXT NOT NULL, + name_pinyin TEXT DEFAULT '', + name_initials TEXT DEFAULT '', type TEXT DEFAULT 'supplier', status TEXT NOT NULL DEFAULT 'enabled', contact TEXT, diff --git a/client/test/golden/goldens/settings_params_a.png b/client/test/golden/goldens/settings_params_a.png deleted file mode 100644 index 2f0ba0c..0000000 Binary files a/client/test/golden/goldens/settings_params_a.png and /dev/null differ diff --git a/client/test/golden/goldens/settings_params_b.png b/client/test/golden/goldens/settings_params_b.png deleted file mode 100644 index 088274c..0000000 Binary files a/client/test/golden/goldens/settings_params_b.png and /dev/null differ diff --git a/client/test/golden/goldens/settings_params_c.png b/client/test/golden/goldens/settings_params_c.png deleted file mode 100644 index bde71f8..0000000 Binary files a/client/test/golden/goldens/settings_params_c.png and /dev/null differ