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:
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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<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 —— 全店汇总(守多租户)
|
||||
func (h *InventoryHandler) Summary(c *gin.Context) {
|
||||
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 = `
|
||||
SELECT
|
||||
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), 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 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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 = ?")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 酒行信息
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
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 |
Reference in New Issue
Block a user