feat(backend): 出入库编码搜索双路匹配+建单填快照;finance 趋势/日期区间;partner/product 拼音搜索

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
wangjia
2026-07-02 22:27:13 +08:00
parent af56055eef
commit 9099c4af99
21 changed files with 610 additions and 39 deletions
+61 -5
View File
@@ -2,6 +2,7 @@ package handler
import ( import (
"net/http" "net/http"
"strconv"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -25,10 +26,13 @@ func (h *FinanceHandler) ListRecords(c *gin.Context) {
shopID := middleware.GetShopID(c) shopID := middleware.GetShopID(c)
var q struct { var q struct {
Type string `form:"type"` Type string `form:"type"`
Month string `form:"month"` // e.g. "2026-04" Month string `form:"month"` // e.g. "2026-04"
Page int `form:"page,default=1"` StartDate string `form:"start_date"` // YYYY-MM-DD(区间过滤,优先于 month)
PageSize int `form:"page_size,default=50"` 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 { if err := c.ShouldBindQuery(&q); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -43,7 +47,18 @@ func (h *FinanceHandler) ListRecords(c *gin.Context) {
if q.Type != "" { if q.Type != "" {
base = base.Where("type = ?", 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) 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}) 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 — 按往来单位汇总未结清 // Summary GET /api/v1/finance/summary — 按往来单位汇总未结清
func (h *FinanceHandler) Summary(c *gin.Context) { func (h *FinanceHandler) Summary(c *gin.Context) {
shopID := middleware.GetShopID(c) shopID := middleware.GetShopID(c)
+113
View File
@@ -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))
// 区间优先于 monthmonth 会被忽略)
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))
}
+24 -2
View File
@@ -77,6 +77,11 @@ func (h *InventoryHandler) List(c *gin.Context) {
like := "%" + keyword + "%" like := "%" + keyword + "%"
args = append(args, like, like, like, like) 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 != "" { if warehouseIDStr != "" {
baseWhere += " AND inv.warehouse_id = ?" baseWhere += " AND inv.warehouse_id = ?"
args = append(args, warehouseIDStr) args = append(args, warehouseIDStr)
@@ -167,24 +172,41 @@ type inventorySummary struct {
InStockQty float64 `json:"in_stock_qty"` // 在库总数量 InStockQty float64 `json:"in_stock_qty"` // 在库总数量
ShortageCount int64 `json:"shortage_count"` // 缺货数(qty<=0 ShortageCount int64 `json:"shortage_count"` // 缺货数(qty<=0
WarningCount int64 `json:"warning_count"` // 预警数(0<qty<安全库存) WarningCount int64 `json:"warning_count"` // 预警数(0<qty<安全库存)
// 月初快照(供 KPI 环比):本月净变动由 inventory_logs 反推
LastMonthSku int64 `json:"last_month_sku"` // 月初存在的库存行数
LastMonthQty float64 `json:"last_month_qty"` // 月初在库总数量
LastMonthValue float64 `json:"last_month_value"` // 月初库存货值
} }
// Summary GET /api/v1/inventory/summary —— 全店汇总(守多租户) // Summary GET /api/v1/inventory/summary —— 全店汇总(守多租户)
func (h *InventoryHandler) Summary(c *gin.Context) { func (h *InventoryHandler) Summary(c *gin.Context) {
shopID := middleware.GetShopID(c) shopID := middleware.GetShopID(c)
now := time.Now()
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
// 月初数量 = 当前数量 − 本月净变动(product=序列号模型下 log 与库存行按 product_id+warehouse_id 一一对应)
const sql = ` const sql = `
SELECT SELECT
COUNT(*) AS sku_count, COUNT(*) AS sku_count,
COALESCE(SUM(inv.quantity * COALESCE(sii.unit_price, inv.unit_price, p.purchase_price)), 0) AS stock_value, COALESCE(SUM(inv.quantity * COALESCE(sii.unit_price, inv.unit_price, p.purchase_price)), 0) AS stock_value,
COALESCE(SUM(inv.quantity), 0) AS in_stock_qty, COALESCE(SUM(inv.quantity), 0) AS in_stock_qty,
COALESCE(SUM(CASE WHEN inv.quantity <= 0 THEN 1 ELSE 0 END), 0) AS shortage_count, COALESCE(SUM(CASE WHEN inv.quantity <= 0 THEN 1 ELSE 0 END), 0) AS shortage_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.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 FROM inventories inv
LEFT JOIN stock_in_items sii ON sii.id = inv.stock_in_item_id 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 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` WHERE inv.shop_id = ? AND inv.deleted_at IS NULL`
var s inventorySummary 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", "查询失败") util.RespondError(c, http.StatusInternalServerError, "QUERY_ERROR", "查询失败")
return return
} }
@@ -287,6 +287,79 @@ func TestInventoryHandler_List_DeletedProductFallsBackToSnapshot(t *testing.T) {
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) 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) { func TestInventoryHandler_AfterStockInApprove(t *testing.T) {
db := testutil.SetupTestDB() db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV008") shop := testutil.CreateTestShop(db, "INV008")
+29 -15
View File
@@ -36,7 +36,11 @@ func (h *PartnerHandler) List(c *gin.Context) {
query = query.Where("FIND_IN_SET(?, type)", t) query = query.Where("FIND_IN_SET(?, type)", t)
} }
if kw := c.Query("keyword"); kw != "" { 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 var total int64
@@ -56,6 +60,7 @@ func (h *PartnerHandler) Create(c *gin.Context) {
return return
} }
p.ShopID = shopID p.ShopID = shopID
p.NamePinyin, p.NameInitials = util.ToPinyin(p.Name)
// 编码未提供时自动生成:供应商 S001/S002…,客户 C001/C002… // 编码未提供时自动生成:供应商 S001/S002…,客户 C001/C002…
if p.Code == "" { if p.Code == "" {
prefix := "S" prefix := "S"
@@ -92,25 +97,34 @@ func (h *PartnerHandler) Update(c *gin.Context) {
Address string `json:"address"` Address string `json:"address"`
BankAccount string `json:"bank_account"` BankAccount string `json:"bank_account"`
CreditLimit float64 `json:"credit_limit"` CreditLimit float64 `json:"credit_limit"`
Status string `json:"status"` // 期初余额:指针区分「未传」与 0,旧客户端不传时不清零
Remark string `json:"remark"` Balance *float64 `json:"balance"`
Status string `json:"status"`
Remark string `json:"remark"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if err := h.db.Model(&p).Where("shop_id = ?", shopID).Updates(map[string]interface{}{ pinyin, initials := util.ToPinyin(req.Name)
"name": req.Name, updates := map[string]interface{}{
"type": req.Type, "name": req.Name,
"code": req.Code, "name_pinyin": pinyin,
"contact": req.Contact, "name_initials": initials,
"phone": req.Phone, "type": req.Type,
"address": req.Address, "code": req.Code,
"bank_account": req.BankAccount, "contact": req.Contact,
"credit_limit": req.CreditLimit, "phone": req.Phone,
"status": req.Status, "address": req.Address,
"remark": req.Remark, "bank_account": req.BankAccount,
}).Error; err != nil { "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()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
+27
View File
@@ -205,6 +205,33 @@ func TestPartnerHandler_List_KeywordSearch(t *testing.T) {
assert.Equal(t, float64(0), resp["total"].(float64)) 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)应返回全部,而非被 ValidatePageSize
// 回退到默认 20(超上限即返回默认值的历史坑)。 // 回退到默认 20(超上限即返回默认值的历史坑)。
func TestPartnerHandler_List_LargePageSizeReturnsAll(t *testing.T) { func TestPartnerHandler_List_LargePageSizeReturnsAll(t *testing.T) {
+5 -2
View File
@@ -39,8 +39,11 @@ func (h *ProductHandler) List(c *gin.Context) {
Where("shop_id = ? AND deleted_at IS NULL", shopID) Where("shop_id = ? AND deleted_at IS NULL", shopID)
if keyword != "" { if keyword != "" {
query = query.Where("name LIKE ? OR code LIKE ? OR barcode LIKE ?", // 原型搜索口径:商品名 / 拼音(全拼+首字母)/ 编码;barcode 保留兼容
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") 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 != "" { if categoryID != "" {
query = query.Where("category_id = ?", categoryID) query = query.Where("category_id = ?", categoryID)
+24
View File
@@ -66,6 +66,30 @@ func TestProductHandler_CRUD(t *testing.T) {
assert.Equal(t, float64(0), resp["total"].(float64)) 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) { func TestProductHandler_HotelIsolation(t *testing.T) {
db := testutil.SetupTestDB() db := testutil.SetupTestDB()
+9 -6
View File
@@ -57,14 +57,15 @@ func (h *StockInHandler) List(c *gin.Context) {
// 酒名「product 主数据名 OR 明细快照名」任一命中——不能用 COALESCE 二选一:历史导入单的 // 酒名「product 主数据名 OR 明细快照名」任一命中——不能用 COALESCE 二选一:历史导入单的
// 明细 product_id 统一指向占位商品「历史导入占位」(p.name 非空),COALESCE 会被占位名劫持, // 明细 product_id 统一指向占位商品「历史导入占位」(p.name 非空),COALESCE 会被占位名劫持,
// 永远取不到真实快照 product_name,导致导入单按真实酒名搜不到。拼音只在 products 上(快照无拼音列)。 // 永远取不到真实快照 product_name,导致导入单按真实酒名搜不到。拼音只在 products 上(快照无拼音列)。
// 编码同理「主数据 p.code OR 快照 it.product_code」双匹配,与页面显示口径(主数据优先)对齐。
query = query.Where( query = query.Where(
"order_no LIKE ?"+ "order_no LIKE ?"+
" OR id IN (SELECT it.order_id FROM stock_in_items it"+ " 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"+ " 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"+ " WHERE it.shop_id = ? AND it.deleted_at IS NULL"+
" AND (p.name LIKE ? OR it.product_name LIKE ? OR it.product_code 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 ?))", " OR p.code LIKE ? OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?))",
like, shopID, like, like, like, like, like, like, shopID, like, like, like, like, like, like,
) )
} }
// 详细搜索多字段(各参数独立可选、组合为 AND) // 详细搜索多字段(各参数独立可选、组合为 AND)
@@ -109,12 +110,14 @@ func applyStockOrderDetailFilters(q *gorm.DB, shopID uint64, c *gin.Context, ite
like := "%" + v + "%" like := "%" + v + "%"
itemConds = append(itemConds, itemConds = append(itemConds,
"(p.name LIKE ? OR it.product_name LIKE ? OR it.product_code LIKE ?"+ "(p.name LIKE ? OR it.product_name LIKE ? OR it.product_code LIKE ?"+
" OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?)") " OR p.code LIKE ? OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?)")
itemArgs = append(itemArgs, like, like, like, like, like) itemArgs = append(itemArgs, like, like, like, like, like, like)
} }
// 编码「主数据 OR 快照」双匹配:存量出库明细快照有空串,只查快照会漏单
if v := strings.TrimSpace(c.Query("product_code")); v != "" { if v := strings.TrimSpace(c.Query("product_code")); v != "" {
itemConds = append(itemConds, "it.product_code LIKE ?") like := "%" + v + "%"
itemArgs = append(itemArgs, "%"+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 != "" { if v := strings.TrimSpace(c.Query("series")); v != "" {
itemConds = append(itemConds, "it.series = ?") itemConds = append(itemConds, "it.series = ?")
+39
View File
@@ -578,6 +578,45 @@ func TestStockInHandler_List_FilterByKeyword(t *testing.T) {
assert.Equal(t, float64(2), parseResponse(w)["total"].(float64)) 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) { func TestStockInHandler_List_FilterByProductName(t *testing.T) {
db := testutil.SetupTestDB() db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SI012") shop := testutil.CreateTestShop(db, "SI012")
+51 -2
View File
@@ -2,6 +2,7 @@ package handler
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -51,14 +52,16 @@ func (h *StockOutHandler) List(c *gin.Context) {
// 酒名「product 主数据名 OR 明细快照名」任一命中——不能用 COALESCE 二选一:历史导入单的 // 酒名「product 主数据名 OR 明细快照名」任一命中——不能用 COALESCE 二选一:历史导入单的
// 明细 product_id 统一指向占位商品「历史导入占位」(p.name 非空),COALESCE 会被占位名劫持, // 明细 product_id 统一指向占位商品「历史导入占位」(p.name 非空),COALESCE 会被占位名劫持,
// 永远取不到真实快照 product_name,导致导入单按真实酒名搜不到。拼音只在 products 上(快照无拼音列)。 // 永远取不到真实快照 product_name,导致导入单按真实酒名搜不到。拼音只在 products 上(快照无拼音列)。
// 编码同理「主数据 p.code OR 快照 it.product_code」双匹配:存量应用建单的明细快照为空串,
// 只查快照会「页面看得到编码、搜索搜不到」。
query = query.Where( query = query.Where(
"order_no LIKE ?"+ "order_no LIKE ?"+
" OR id IN (SELECT it.order_id FROM stock_out_items it"+ " 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"+ " 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"+ " WHERE it.shop_id = ? AND it.deleted_at IS NULL"+
" AND (p.name LIKE ? OR it.product_name LIKE ? OR it.product_code 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 ?))", " OR p.code LIKE ? OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?))",
like, shopID, like, like, like, like, like, like, shopID, like, like, like, like, like, like,
) )
} }
// 详细搜索多字段(各参数独立可选、组合为 AND) // 详细搜索多字段(各参数独立可选、组合为 AND)
@@ -147,6 +150,44 @@ func (h *StockOutHandler) Get(c *gin.Context) {
// Create POST /api/v1/stock-out/orders // 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) { func (h *StockOutHandler) Create(c *gin.Context) {
shopID := middleware.GetShopID(c) shopID := middleware.GetShopID(c)
operatorID := middleware.GetUserID(c) operatorID := middleware.GetUserID(c)
@@ -186,6 +227,11 @@ func (h *StockOutHandler) Create(c *gin.Context) {
} }
req.TotalAmount = total 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 { if err := h.db.Create(&req).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -222,6 +268,9 @@ func (h *StockOutHandler) Update(c *gin.Context) {
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
total += req.Items[i].Quantity * req.Items[i].SalePrice total += req.Items[i].Quantity * req.Items[i].SalePrice
} }
if err := fillStockOutItemSnapshots(tx, shopID, req.Items); err != nil {
return err
}
updates := map[string]interface{}{ updates := map[string]interface{}{
"warehouse_id": req.WarehouseID, "warehouse_id": req.WarehouseID,
"partner_id": req.PartnerID, "partner_id": req.PartnerID,
+113 -7
View File
@@ -133,45 +133,151 @@ func TestStockOutHandler_List(t *testing.T) {
assert.Equal(t, float64(2), resp["total"].(float64)) assert.Equal(t, float64(2), resp["total"].(float64))
} }
// 按商品编码精确反查出库单(明细 product_code = ? // 按商品编码反查出库单:快照以主数据为准(建单回填),客户端传的 product_code 被覆盖
func TestStockOutHandler_List_FilterByProductCode(t *testing.T) { func TestStockOutHandler_List_FilterByProductCode(t *testing.T) {
db := testutil.SetupTestDB() db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SO_CODE") shop := testutil.CreateTestShop(db, "SO_CODE")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") 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") token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db) r := setupProtectedRouter(db)
// 单1 不传 product_code(真实客户端行为);单2 传假编码,应被主数据覆盖
makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
"warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{ "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{}{ makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
"warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{ "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 单 // 按主数据编码命中 1 单
w := makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=ZXZ-FIND", token, nil) w := makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=P-ZXZFind", token, nil)
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) 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) w = makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=ZXZ", token, nil)
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) 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 // 不存在的编码 → 0
w = makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=NOPE", token, nil) w = makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=NOPE", token, nil)
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, float64(0), parseResponse(w)["total"].(float64)) 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) { func TestStockOutHandler_List_FilterByKeyword(t *testing.T) {
db := testutil.SetupTestDB() db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SO011") shop := testutil.CreateTestShop(db, "SO011")
@@ -87,10 +87,20 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine {
// 库存路由 // 库存路由
inv := api.Group("/inventory") inv := api.Group("/inventory")
inv.GET("", inventoryH.List) inv.GET("", inventoryH.List)
inv.GET("/summary", inventoryH.Summary)
inv.GET("/logs", inventoryH.Logs) inv.GET("/logs", inventoryH.Logs)
inv.POST("/checks", inventoryH.CreateCheck) inv.POST("/checks", inventoryH.CreateCheck)
inv.GET("/checks/:id", inventoryH.GetCheck) 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 := api.Group("/license")
license.POST("/activate", licenseH.Activate) license.POST("/activate", licenseH.Activate)
+3
View File
@@ -4,6 +4,9 @@ type Partner struct {
TenantBase TenantBase
Code string `gorm:"size:50" json:"code"` Code string `gorm:"size:50" json:"code"`
Name string `gorm:"size:200;not null" json:"name" binding:"required"` 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"` Type string `gorm:"type:set('supplier','customer');default:'supplier'" json:"type"`
Contact string `gorm:"size:50" json:"contact"` Contact string `gorm:"size:50" json:"contact"`
Phone string `gorm:"size:30" json:"phone"` Phone string `gorm:"size:30" json:"phone"`
+1
View File
@@ -213,6 +213,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
finance.PUT("/records/:id/close", financeH.Close) finance.PUT("/records/:id/close", financeH.Close)
finance.PUT("/records/close-by-ref", financeH.CloseByRef) finance.PUT("/records/close-by-ref", financeH.CloseByRef)
finance.GET("/summary", financeH.Summary) finance.GET("/summary", financeH.Summary)
finance.GET("/trend", financeH.Trend)
} }
// 酒行信息 // 酒行信息
+24
View File
@@ -13,6 +13,7 @@ import (
"github.com/wangjia/jiu/backend/internal/model" "github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/router" "github.com/wangjia/jiu/backend/internal/router"
"github.com/wangjia/jiu/backend/internal/service" "github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/internal/util"
) )
func main() { func main() {
@@ -35,6 +36,9 @@ func main() {
// 自动迁移(GORM AutoMigrate 只增不删,生产安全) // 自动迁移(GORM AutoMigrate 只增不删,生产安全)
autoMigrate(db) autoMigrate(db)
// 回填存量往来单位的拼音搜索列(幂等,只处理空值行)
backfillPartnerPinyin(db)
// 启动会话/失败登录保留期清理任务(后台 goroutine) // 启动会话/失败登录保留期清理任务(后台 goroutine)
service.StartSessionCleanup(db, config.C.Session.RetentionDays) service.StartSessionCleanup(db, config.C.Session.RetentionDays)
@@ -153,3 +157,23 @@ func autoMigrate(db *gorm.DB) {
} }
log.Println("AutoMigrate completed") 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)
}
}
+2
View File
@@ -246,6 +246,8 @@ CREATE TABLE IF NOT EXISTS `partners` (
`shop_id` BIGINT UNSIGNED NOT NULL, `shop_id` BIGINT UNSIGNED NOT NULL,
`code` VARCHAR(50) DEFAULT NULL, `code` VARCHAR(50) DEFAULT NULL,
`name` VARCHAR(200) NOT 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 '可同时是供应商和客户', `type` SET('supplier','customer') NOT NULL DEFAULT 'supplier' COMMENT '可同时是供应商和客户',
`contact` VARCHAR(50) DEFAULT NULL COMMENT '联系人', `contact` VARCHAR(50) DEFAULT NULL COMMENT '联系人',
`phone` VARCHAR(30) DEFAULT NULL, `phone` VARCHAR(30) DEFAULT NULL,
+2
View File
@@ -299,6 +299,8 @@ func SetupTestDB() *gorm.DB {
shop_id INTEGER NOT NULL, shop_id INTEGER NOT NULL,
code TEXT, code TEXT,
name TEXT NOT NULL, name TEXT NOT NULL,
name_pinyin TEXT DEFAULT '',
name_initials TEXT DEFAULT '',
type TEXT DEFAULT 'supplier', type TEXT DEFAULT 'supplier',
status TEXT NOT NULL DEFAULT 'enabled', status TEXT NOT NULL DEFAULT 'enabled',
contact TEXT, contact TEXT,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB