Compare commits

..

3 Commits

Author SHA1 Message Date
wangjia 69890b9935 chore: release server-v1.0.84
Deploy Server / release-deploy-server (push) Successful in 57s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-02 22:27:13 +08:00
wangjia 493e9b45b3 devops: 割接后 server 部署轨道切为仅 Ali 主轨(移除 EC2 轨)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-02 22:27:13 +08:00
wangjia 9099c4af99 feat(backend): 出入库编码搜索双路匹配+建单填快照;finance 趋势/日期区间;partner/product 拼音搜索
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-02 22:27:13 +08:00
25 changed files with 681 additions and 87 deletions
+3 -16
View File
@@ -36,22 +36,9 @@ jobs:
GITEA_REPOSITORY: ${{ gitea.repository }} GITEA_REPOSITORY: ${{ gitea.repository }}
run: sh scripts/ci/release-server.sh "${{ gitea.ref_name }}" run: sh scripts/ci/release-server.sh "${{ gitea.ref_name }}"
- name: Deploy → EC2 # 2026-07-02 割接后阿里机即线上主轨(EC2 jiu 已停用,Deploy→EC2 轨已移除,
env: # 绝不能再部署 EC2——那会把停用的后端对着过时旧库重新拉起)。
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} - name: Deploy → Ali
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
GITEA_REPOSITORY: ${{ gitea.repository }}
DEPLOY_TARGET: ec2
EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }}
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }}
run: sh scripts/ci/deploy-server.sh "${{ gitea.ref_name }}"
# 迁移期影子目标:部署到阿里机(只换二进制 + reload 独立 nginxjiu 保持 stopped)。
# if: always() → EC2 失败也照样尝试;continue-on-error → 阿里失败不让流水线变红。
- name: Deploy → Ali (migration shadow)
if: always()
continue-on-error: true
env: env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }} FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
+15
View File
@@ -5,6 +5,21 @@
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.84] - 2026-07-02
### 新功能
- 财务管理新增「收支趋势」月度汇总接口(近 N 个月销售/采购金额),供财务页趋势柱状图使用。
- 财务流水支持按日期区间筛选(本季 / 自定义时间范围)。
### 改进
- 往来单位支持拼音 / 首字母搜索(存量数据启动时自动回填拼音)。
- 商品档案关键词搜索支持商品编码与拼音 / 首字母。
- 库存列表搜索合并为单一关键词(商品名 / 拼音 / 编码一个框搞定)。
### 修复
- 出库 / 入库单按商品编码搜索:历史录入的单据(明细快照编码为空)现在也能搜到——搜索改为「商品主数据编码 + 明细快照编码」双路匹配;新建单据同时自动写入商品快照。
- 运维:割接后 server 部署轨道切换为阿里机主轨(原 EC2 轨移除,避免误启已停用的旧后端)。
## [1.0.83] - 2026-07-02 ## [1.0.83] - 2026-07-02
### 修复 ### 修复
+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

+29 -19
View File
@@ -1,18 +1,27 @@
# 阿里云独立 nginx 版(宿主机直接终结 TLS,不经 pangolin)。 # 阿里云独立 nginx 版(宿主机直接终结 TLS,不经 pangolin)。
# 与 nginx-jiu.confEC2 pangolin 容器版,listen 127.0.0.1:8445)等价 # 与 nginx-jiu.confEC2 pangolin 容器版,listen 127.0.0.1:8445)等价
# 仅入口监听改为对公网 443 直连。CI deploy-server.sh 的 ali 分支落到 # CI deploy-server.sh 的 ali 分支落到 /etc/nginx/conf.d/jiu.conf。
# /etc/nginx/conf.d/jiu.conf。 #
# 过渡期入口 = 8443 明文 HTTP,裸 IP 直连 http://182.92.213.171:8443
#(备案未通过前 80/443/8080 被阿里云拦截,走非标端口;不建过渡子域,
# 证书签给域名对不上 IP,故过渡期不做 TLS——登录口令/JWT 明文传输,仅限过渡;
# 备案通过后改回 listen 443 ssl + 域名 + 80→443 跳转块)。
# 后端上游 = 127.0.0.1:8081(非 8080ali 上 8080 已被 pay 项目 payd 占用;
# 对应 /opt/jiu/config/production.env 的 SERVER_PORT=8081)。
limit_req_zone $binary_remote_addr zone=jiu_pub:10m rate=10r/s; limit_req_zone $binary_remote_addr zone=jiu_pub:10m rate=10r/s;
limit_req_status 429; limit_req_status 429;
server { server {
listen 443 ssl http2; listen 8443 default_server;
server_name jiu.51yanmei.com; server_name _;
ssl_certificate /etc/letsencrypt/live/jiu.51yanmei.com/fullchain.pem; # 备案通过切回 443 时恢复(证书路径仍有效,jiu.51yanmei.com2026-09-21 到期):
ssl_certificate_key /etc/letsencrypt/live/jiu.51yanmei.com/privkey.pem; # listen 443 ssl http2;
ssl_protocols TLSv1.2 TLSv1.3; # server_name jiu.51yanmei.com;
ssl_ciphers HIGH:!aNULL:!MD5; # ssl_certificate /etc/letsencrypt/live/jiu.51yanmei.com/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/jiu.51yanmei.com/privkey.pem;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 20m; client_max_body_size 20m;
@@ -25,7 +34,7 @@ server {
# 文件导入接口(超时更长) # 文件导入接口(超时更长)
location ~ ^/api/v1/import/ { location ~ ^/api/v1/import/ {
proxy_pass http://127.0.0.1:8080; proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 300s; proxy_read_timeout 300s;
@@ -34,7 +43,7 @@ server {
# 未鉴权公开/登录接口:加最外层 per-IP 限流 # 未鉴权公开/登录接口:加最外层 per-IP 限流
location ~ ^/api/v1/(public|auth)/ { location ~ ^/api/v1/(public|auth)/ {
limit_req zone=jiu_pub burst=20 nodelay; limit_req zone=jiu_pub burst=20 nodelay;
proxy_pass http://127.0.0.1:8080; proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s; proxy_read_timeout 30s;
@@ -42,7 +51,7 @@ server {
# API 反向代理 # API 反向代理
location ~ ^/(api|health|version) { location ~ ^/(api|health|version) {
proxy_pass http://127.0.0.1:8080; proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s; proxy_read_timeout 30s;
@@ -65,7 +74,7 @@ server {
# 公开商品详情页(扫码跳转)→ 后端注入 OG 标签 # 公开商品详情页(扫码跳转)→ 后端注入 OG 标签
location ~ ^/product/ { location ~ ^/product/ {
limit_req zone=jiu_pub burst=20 nodelay; limit_req zone=jiu_pub burst=20 nodelay;
proxy_pass http://127.0.0.1:8080; proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s; proxy_read_timeout 30s;
@@ -75,7 +84,7 @@ server {
location ~ ^/app/product/ { location ~ ^/app/product/ {
limit_req zone=jiu_pub burst=20 nodelay; limit_req zone=jiu_pub burst=20 nodelay;
rewrite ^/app(/product/.+)$ $1 break; rewrite ^/app(/product/.+)$ $1 break;
proxy_pass http://127.0.0.1:8080; proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s; proxy_read_timeout 30s;
@@ -96,8 +105,9 @@ server {
} }
} }
server { # 备案通过、入口切回 443 后再恢复此跳转块(80 未备案期间被拦截,挂了也无意义):
listen 80; # server {
server_name jiu.51yanmei.com; # listen 80;
return 301 https://$host$request_uri; # server_name jiu.51yanmei.com;
} # return 301 https://$host$request_uri;
# }
+24 -13
View File
@@ -4,13 +4,14 @@
# assets by tag (manual rollback). Does NOT touch the web app, marketing site, # assets by tag (manual rollback). Does NOT touch the web app, marketing site,
# or version.yaml. # or version.yaml.
# #
# Target is EC2 by default. During the EC2→Ali migration, set DEPLOY_TARGET=ali # Targets (post-cutover 2026-07-02: Ali is the live primary; the EC2 jiu stack
# plus DEPLOY_HOST/DEPLOY_USER/DEPLOY_SSH_KEY to deploy to the Alibaba Cloud box. # is decommissioned — deploying there would resurrect a backend against a stale
# - ec2 (default): stop jiu → swap binary → start jiu → health check → # database, so the workflow no longer calls the ec2 branch):
# reload nginx inside the pangolin-edge container. # - ali: stop jiu → swap binary → start jiu → health check (:8081) →
# - ali: swap binary only (jiu.service stays stopped — the Ali DB is a # reload the host nginx (transition listen-8443 config).
# read-only replica until cutover; backend is started at cutover) → # - ec2 (legacy default, kept for manual rollback of pre-cutover tags only):
# reload the host nginx with the standalone (listen 443) config. # stop jiu → swap binary → start jiu → health check → reload nginx
# inside the pangolin-edge container.
set -euo pipefail set -euo pipefail
# shellcheck source=scripts/ci/lib-forgejo.sh # shellcheck source=scripts/ci/lib-forgejo.sh
@@ -53,14 +54,13 @@ if [ -f dist/jiu-gencode ]; then
fi fi
if [ "$DEPLOY_TARGET" = "ali" ]; then if [ "$DEPLOY_TARGET" = "ali" ]; then
# --- Alibaba Cloud (shadow target, pre-cutover) --- # --- Alibaba Cloud (live primary since the 2026-07-02 cutover) ---
# Swap the binary but DO NOT start jiu: the Ali DB is a read-only replica, so # Full swap-and-restart: jiu.service runs against the promoted local primary
# the backend's startup AutoMigrate/backfill would fail. jiu.service is started # DB on :8081; nginx serves the transition listen-8443 config.
# only at cutover (after the replica is promoted to primary). Reload the host
# nginx with the standalone listen-443 config.
${SSH} "${TARGET_USER}@${TARGET_HOST}" << 'ENDSSH' ${SSH} "${TARGET_USER}@${TARGET_HOST}" << 'ENDSSH'
set -e set -e
systemctl stop jiu
cp /tmp/jiu-server /opt/jiu/backend/jiu-server cp /tmp/jiu-server /opt/jiu/backend/jiu-server
chmod +x /opt/jiu/backend/jiu-server chmod +x /opt/jiu/backend/jiu-server
@@ -73,10 +73,21 @@ if [ -f /tmp/jiu-gencode ]; then
fi fi
fi fi
systemctl start jiu
echo "Waiting for health check..."
for i in $(seq 1 30); do
if curl -f http://localhost:8081/health > /dev/null 2>&1; then
echo "Health check passed"
break
fi
sleep 2
done
curl -f http://localhost:8081/health > /dev/null || { echo "Health check failed!"; exit 1; }
cp /tmp/nginx-jiu.conf /etc/nginx/conf.d/jiu.conf cp /tmp/nginx-jiu.conf /etc/nginx/conf.d/jiu.conf
nginx -t && systemctl reload nginx nginx -t && systemctl reload nginx
echo "Ali server staged (binary swapped, jiu.service left stopped, nginx reloaded)" echo "Ali server deploy complete!"
ENDSSH ENDSSH
else else
# --- EC2 (live primary) --- # --- EC2 (live primary) ---