Compare commits

..

5 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
wangjia af56055eef chore: release server-v1.0.83
Deploy Server / release-deploy-server (push) Successful in 2m10s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:47:13 +08:00
wangjia d0c71a9252 fix(backend): 往来单位下拉返回全部 + 入库售价写入产品
- partner List 分页上限 200→1000:避免下拉请求大页被 ValidatePageSize
  回退到默认 20(超上限返回默认值而非截断),供应商/客户下拉一次取全部
- 入库明细 sale_price 建独立产品时写入 product.SalePrice(建议售价);
  SalePrice 为 gorm:"-" 仅作建库入参,不落 stock_in_items 表
- 补 partner 大页返回全部、入库售价入产品两条回归测试

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:44:50 +08:00
26 changed files with 759 additions and 91 deletions
+3 -16
View File
@@ -36,22 +36,9 @@ jobs:
GITEA_REPOSITORY: ${{ gitea.repository }}
run: sh scripts/ci/release-server.sh "${{ gitea.ref_name }}"
- name: Deploy → EC2
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
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
# 2026-07-02 割接后阿里机即线上主轨(EC2 jiu 已停用,Deploy→EC2 轨已移除,
# 绝不能再部署 EC2——那会把停用的后端对着过时旧库重新拉起)。
- name: Deploy → Ali
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
+23
View File
@@ -5,6 +5,29 @@
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).
## [1.0.84] - 2026-07-02
### 新功能
- 财务管理新增「收支趋势」月度汇总接口(近 N 个月销售/采购金额),供财务页趋势柱状图使用。
- 财务流水支持按日期区间筛选(本季 / 自定义时间范围)。
### 改进
- 往来单位支持拼音 / 首字母搜索(存量数据启动时自动回填拼音)。
- 商品档案关键词搜索支持商品编码与拼音 / 首字母。
- 库存列表搜索合并为单一关键词(商品名 / 拼音 / 编码一个框搞定)。
### 修复
- 出库 / 入库单按商品编码搜索:历史录入的单据(明细快照编码为空)现在也能搜到——搜索改为「商品主数据编码 + 明细快照编码」双路匹配;新建单据同时自动写入商品快照。
- 运维:割接后 server 部署轨道切换为阿里机主轨(原 EC2 轨移除,避免误启已停用的旧后端)。
## [1.0.83] - 2026-07-02
### 修复
- 供应商 / 客户下拉修复:现在一次返回全部往来单位,不再被截断到 20 条(此前录单和筛选的下拉最多只显示 20 个)。
### 改进
- 入库录单填写的「售价」现在会写入对应商品,作为该商品的建议售价,后续出库可直接带出。
## [1.0.82] - 2026-07-02
### 新功能
+61 -5
View File
@@ -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)
+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 + "%"
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")
+32 -16
View File
@@ -25,7 +25,9 @@ func (h *PartnerHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
pageSize = util.ValidatePageSize(pageSize, 20, 200)
// 往来单位是有界的参考数据,下拉需一次取全部;放宽上限到 1000,
// 避免前端请求大页被 ValidatePageSize 回退到默认 20(超上限即返回默认值,非截断)。
pageSize = util.ValidatePageSize(pageSize, 20, 1000)
query := h.db.Model(&model.Partner{}).
Where("shop_id = ? AND deleted_at IS NULL", shopID)
@@ -34,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
@@ -54,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"
@@ -90,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
}
+50
View File
@@ -205,6 +205,56 @@ 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) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PT007")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
for i := 0; i < 25; i++ {
makeRequest(r, "POST", "/api/v1/partners", token, map[string]interface{}{
"name": fmt.Sprintf("Partner %02d", i),
"type": "supplier",
})
}
w := makeRequest(r, "GET", "/api/v1/partners?page_size=1000", token, nil)
resp := parseResponse(w)
assert.Equal(t, float64(25), resp["total"].(float64))
assert.Equal(t, float64(1000), resp["page_size"].(float64))
assert.Len(t, resp["data"].([]interface{}), 25)
}
func TestPartnerHandler_ShopIDFromToken(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PT007")
+7 -3
View File
@@ -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)
@@ -88,7 +91,7 @@ func nextProductCode(tx *gorm.DB, shopID uint64) (string, error) {
// createIndependentProduct 为入库明细新建一个独立产品(特有产品/序列号),返回新 product。
// "入库每行 = 一个特有产品"模型:每条明细建一个独立 product、发新序列号,不按名称复用。
// 含 nextProductCode 自增 + 撞 uk_shop_code 唯一约束时重试。必须在事务内调用。
func createIndependentProduct(tx *gorm.DB, shopID uint64, name, series, spec, batchNo string, prodDate *model.Date, price float64) (model.Product, error) {
func createIndependentProduct(tx *gorm.DB, shopID uint64, name, series, spec, batchNo string, prodDate *model.Date, price, salePrice float64) (model.Product, error) {
namePinyin, nameInitials := util.ToPinyin(name)
var prod model.Product
var err error
@@ -107,6 +110,7 @@ func createIndependentProduct(tx *gorm.DB, shopID uint64, name, series, spec, ba
BatchNo: batchNo,
ProductionDate: prodDate,
PurchasePrice: price,
SalePrice: salePrice,
NamePinyin: namePinyin,
NameInitials: nameInitials,
}
+24
View File
@@ -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()
+11 -8
View File
@@ -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 = ?")
@@ -228,7 +231,7 @@ func (h *StockInHandler) Create(c *gin.Context) {
if it.BatchNo == "" {
it.BatchNo = fmt.Sprintf("%s-%02d", req.OrderNo, i+1)
}
prod, e := createIndependentProduct(tx, shopID, it.ProductName, it.Series, it.Spec, it.BatchNo, it.ProductionDate, it.UnitPrice)
prod, e := createIndependentProduct(tx, shopID, it.ProductName, it.Series, it.Spec, it.BatchNo, it.ProductionDate, it.UnitPrice, it.SalePrice)
if e != nil {
return e
}
@@ -277,7 +280,7 @@ func (h *StockInHandler) Update(c *gin.Context) {
it.BatchNo = fmt.Sprintf("%s-%02d", order.OrderNo, i+1)
}
// 编辑草稿:旧明细已删,每条按新模型重建独立产品(旧草稿 product 无库存,暂留待后续清理)
prod, e := createIndependentProduct(tx, shopID, it.ProductName, it.Series, it.Spec, it.BatchNo, it.ProductionDate, it.UnitPrice)
prod, e := createIndependentProduct(tx, shopID, it.ProductName, it.Series, it.Spec, it.BatchNo, it.ProductionDate, it.UnitPrice, it.SalePrice)
if e != nil {
return e
}
+77
View File
@@ -75,6 +75,44 @@ func TestStockInHandler_FullFlow(t *testing.T) {
assert.Equal(t, float64(10), logs[0].Quantity)
}
// 入库明细带 sale_price 时,为该行新建的独立产品应写入 product.SalePrice(建议售价)。
func TestStockInHandler_Create_SalePriceToProduct(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SISALE")
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)
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{
"warehouse_id": warehouse.ID,
"order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{
{
"product_name": "茅台飞天",
"series": "53度",
"spec": "500ml",
"quantity": 3.0,
"unit_price": 2680.0,
"sale_price": 2980.0,
},
},
})
require.Equal(t, http.StatusCreated, w.Code)
orderID := extractID(w)
// 找到该单据明细新建的独立产品,核对进价/售价
var item model.StockInItem
require.NoError(t, db.Where("order_id = ?", orderID).First(&item).Error)
require.NotZero(t, item.ProductID)
var prod model.Product
require.NoError(t, db.Where("id = ? AND shop_id = ?", item.ProductID, shop.ID).First(&prod).Error)
assert.Equal(t, 2680.0, prod.PurchasePrice)
assert.Equal(t, 2980.0, prod.SalePrice)
// sale_price 不落 stock_in_itemsgorm:"-"),仅作产品建库入参
assert.Equal(t, "茅台飞天", prod.Name)
}
func TestStockInHandler_List(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SI002")
@@ -540,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")
+51 -2
View File
@@ -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,
+113 -7
View File
@@ -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)
+3
View File
@@ -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"`
+2
View File
@@ -42,6 +42,8 @@ type StockInItem struct {
Spec string `gorm:"size:100" json:"spec"`
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"`
UnitPrice float64 `gorm:"type:decimal(16,2);default:0" json:"unit_price"`
// 建议售价:仅用于入库建产品时写入 product.SalePrice,不落 stock_in_items 表(gorm:"-")。
SalePrice float64 `gorm:"-" json:"sale_price"`
TotalPrice float64 `gorm:"type:decimal(16,2);default:0" json:"total_price"`
// 已退数量:0=未退,=Quantity 表示整行已退单(整行退,不做部分数量)
ReturnedQuantity float64 `gorm:"type:decimal(12,3);default:0" json:"returned_quantity"`
+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/close-by-ref", financeH.CloseByRef)
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/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)
}
}
+2
View File
@@ -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,
+2
View File
@@ -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

+29 -19
View File
@@ -1,18 +1,27 @@
# 阿里云独立 nginx 版(宿主机直接终结 TLS,不经 pangolin)。
# 与 nginx-jiu.confEC2 pangolin 容器版,listen 127.0.0.1:8445)等价
# 仅入口监听改为对公网 443 直连。CI deploy-server.sh 的 ali 分支落到
# /etc/nginx/conf.d/jiu.conf。
# 与 nginx-jiu.confEC2 pangolin 容器版,listen 127.0.0.1:8445)等价
# CI deploy-server.sh 的 ali 分支落到 /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_status 429;
server {
listen 443 ssl http2;
server_name jiu.51yanmei.com;
listen 8443 default_server;
server_name _;
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;
# 备案通过切回 443 时恢复(证书路径仍有效,jiu.51yanmei.com2026-09-21 到期):
# listen 443 ssl http2;
# server_name jiu.51yanmei.com;
# 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;
@@ -25,7 +34,7 @@ server {
# 文件导入接口(超时更长)
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 X-Real-IP $remote_addr;
proxy_read_timeout 300s;
@@ -34,7 +43,7 @@ server {
# 未鉴权公开/登录接口:加最外层 per-IP 限流
location ~ ^/api/v1/(public|auth)/ {
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 X-Real-IP $remote_addr;
proxy_read_timeout 30s;
@@ -42,7 +51,7 @@ server {
# API 反向代理
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 X-Real-IP $remote_addr;
proxy_read_timeout 30s;
@@ -65,7 +74,7 @@ server {
# 公开商品详情页(扫码跳转)→ 后端注入 OG 标签
location ~ ^/product/ {
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 X-Real-IP $remote_addr;
proxy_read_timeout 30s;
@@ -75,7 +84,7 @@ server {
location ~ ^/app/product/ {
limit_req zone=jiu_pub burst=20 nodelay;
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 X-Real-IP $remote_addr;
proxy_read_timeout 30s;
@@ -96,8 +105,9 @@ server {
}
}
server {
listen 80;
server_name jiu.51yanmei.com;
return 301 https://$host$request_uri;
}
# 备案通过、入口切回 443 后再恢复此跳转块(80 未备案期间被拦截,挂了也无意义):
# server {
# listen 80;
# 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,
# or version.yaml.
#
# Target is EC2 by default. During the EC2→Ali migration, set DEPLOY_TARGET=ali
# plus DEPLOY_HOST/DEPLOY_USER/DEPLOY_SSH_KEY to deploy to the Alibaba Cloud box.
# - ec2 (default): stop jiu → swap binary → start jiu → health check →
# reload nginx inside the pangolin-edge container.
# - ali: swap binary only (jiu.service stays stopped — the Ali DB is a
# read-only replica until cutover; backend is started at cutover) →
# reload the host nginx with the standalone (listen 443) config.
# Targets (post-cutover 2026-07-02: Ali is the live primary; the EC2 jiu stack
# is decommissioned — deploying there would resurrect a backend against a stale
# database, so the workflow no longer calls the ec2 branch):
# - ali: stop jiu → swap binary → start jiu → health check (:8081) →
# reload the host nginx (transition listen-8443 config).
# - ec2 (legacy default, kept for manual rollback of pre-cutover tags only):
# stop jiu → swap binary → start jiu → health check → reload nginx
# inside the pangolin-edge container.
set -euo pipefail
# shellcheck source=scripts/ci/lib-forgejo.sh
@@ -53,14 +54,13 @@ if [ -f dist/jiu-gencode ]; then
fi
if [ "$DEPLOY_TARGET" = "ali" ]; then
# --- Alibaba Cloud (shadow target, pre-cutover) ---
# Swap the binary but DO NOT start jiu: the Ali DB is a read-only replica, so
# the backend's startup AutoMigrate/backfill would fail. jiu.service is started
# only at cutover (after the replica is promoted to primary). Reload the host
# nginx with the standalone listen-443 config.
# --- Alibaba Cloud (live primary since the 2026-07-02 cutover) ---
# Full swap-and-restart: jiu.service runs against the promoted local primary
# DB on :8081; nginx serves the transition listen-8443 config.
${SSH} "${TARGET_USER}@${TARGET_HOST}" << 'ENDSSH'
set -e
systemctl stop jiu
cp /tmp/jiu-server /opt/jiu/backend/jiu-server
chmod +x /opt/jiu/backend/jiu-server
@@ -73,10 +73,21 @@ if [ -f /tmp/jiu-gencode ]; then
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
nginx -t && systemctl reload nginx
echo "Ali server staged (binary swapped, jiu.service left stopped, nginx reloaded)"
echo "Ali server deploy complete!"
ENDSSH
else
# --- EC2 (live primary) ---