feat(backend): 出入库汇总近30天滚动窗口 + 跨租户安全加固
- summaryBounds:本月/近30天滚动双口径(stock-in/out Summary ?window=rolling30) - security(SEC-001):新增 ownership.go ensureShopRef 写入侧防线(stock-in/out/finance/ 盘点建单的 warehouse/partner/product 外键归属校验);读取侧全部 Preload 补 shop_id 作用域,finance Summary JOIN 补租户条件;回归测试 CrossTenantRefs - security(SEC-002):release 模式 JWT 密钥为空/默认值时拒绝启动 - gofmt 对齐若干 model/cmd 文件 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
@@ -7,9 +7,9 @@
|
|||||||
// product_id 指错了。
|
// product_id 指错了。
|
||||||
//
|
//
|
||||||
// 修复(就地、不删数据、幂等):遍历目标门店活跃库存行,对每行:
|
// 修复(就地、不删数据、幂等):遍历目标门店活跃库存行,对每行:
|
||||||
// 1. 按 (shop_id, code=inv.product_code) 找 product;
|
// 1. 按 (shop_id, code=inv.product_code) 找 product;
|
||||||
// 2. 找不到就用该行快照新建一个独立 product(保留原始编号);
|
// 2. 找不到就用该行快照新建一个独立 product(保留原始编号);
|
||||||
// 3. 把 inv.product_id 重指到它。
|
// 3. 把 inv.product_id 重指到它。
|
||||||
//
|
//
|
||||||
// 用法(在 backend/ 目录下):
|
// 用法(在 backend/ 目录下):
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import "testing"
|
|||||||
|
|
||||||
func TestMapStatus(t *testing.T) {
|
func TestMapStatus(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"已审核": "approved",
|
"已审核": "approved",
|
||||||
"待审核": "pending",
|
"待审核": "pending",
|
||||||
"被驳回": "rejected",
|
"被驳回": "rejected",
|
||||||
"作废": "rejected",
|
"作废": "rejected",
|
||||||
"": "draft",
|
"": "draft",
|
||||||
"未知": "draft",
|
"未知": "draft",
|
||||||
" 已审核 ": "approved",
|
" 已审核 ": "approved",
|
||||||
}
|
}
|
||||||
for in, want := range cases {
|
for in, want := range cases {
|
||||||
@@ -21,12 +21,12 @@ func TestMapStatus(t *testing.T) {
|
|||||||
|
|
||||||
func TestParseFloatLoose(t *testing.T) {
|
func TestParseFloatLoose(t *testing.T) {
|
||||||
cases := map[string]float64{
|
cases := map[string]float64{
|
||||||
"": 0,
|
"": 0,
|
||||||
" ": 0,
|
" ": 0,
|
||||||
"123": 123,
|
"123": 123,
|
||||||
"1,234.50": 1234.5,
|
"1,234.50": 1234.5,
|
||||||
"abc": 0,
|
"abc": 0,
|
||||||
"-9.9": -9.9,
|
"-9.9": -9.9,
|
||||||
}
|
}
|
||||||
for in, want := range cases {
|
for in, want := range cases {
|
||||||
if got := parseFloatLoose(in); got != want {
|
if got := parseFloatLoose(in); got != want {
|
||||||
|
|||||||
+24
-24
@@ -142,21 +142,21 @@ func main() {
|
|||||||
hash := mustHash("password123")
|
hash := mustHash("password123")
|
||||||
admin := upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "admin", func() any {
|
admin := upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "admin", func() any {
|
||||||
return &model.User{
|
return &model.User{
|
||||||
ShopID: shop.ID,
|
ShopID: shop.ID,
|
||||||
Username: "admin", PasswordHash: hash, RealName: "张三(管理员)",
|
Username: "admin", PasswordHash: hash, RealName: "张三(管理员)",
|
||||||
Phone: "13800000001", Role: "admin", IsActive: true,
|
Phone: "13800000001", Role: "admin", IsActive: true,
|
||||||
}
|
}
|
||||||
}).(model.User)
|
}).(model.User)
|
||||||
operator := upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "operator", func() any {
|
operator := upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "operator", func() any {
|
||||||
return &model.User{
|
return &model.User{
|
||||||
ShopID: shop.ID,
|
ShopID: shop.ID,
|
||||||
Username: "operator", PasswordHash: hash, RealName: "李四(操作员)",
|
Username: "operator", PasswordHash: hash, RealName: "李四(操作员)",
|
||||||
Phone: "13800000002", Role: "operator", IsActive: true,
|
Phone: "13800000002", Role: "operator", IsActive: true,
|
||||||
}
|
}
|
||||||
}).(model.User)
|
}).(model.User)
|
||||||
upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "test", func() any {
|
upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "test", func() any {
|
||||||
return &model.User{
|
return &model.User{
|
||||||
ShopID: shop.ID,
|
ShopID: shop.ID,
|
||||||
Username: "test", PasswordHash: hash, RealName: "王五(只读)",
|
Username: "test", PasswordHash: hash, RealName: "王五(只读)",
|
||||||
Phone: "13800000003", Role: "readonly", IsActive: true,
|
Phone: "13800000003", Role: "readonly", IsActive: true,
|
||||||
}
|
}
|
||||||
@@ -168,13 +168,13 @@ func main() {
|
|||||||
wh1 := upsert(db, &model.Warehouse{}, "shop_id = ? AND name = ?", shop.ID, "主仓库", func() any {
|
wh1 := upsert(db, &model.Warehouse{}, "shop_id = ? AND name = ?", shop.ID, "主仓库", func() any {
|
||||||
return &model.Warehouse{
|
return &model.Warehouse{
|
||||||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||||
Name: "主仓库", Location: "A栋1层东侧", IsDefault: true,
|
Name: "主仓库", Location: "A栋1层东侧", IsDefault: true,
|
||||||
}
|
}
|
||||||
}).(model.Warehouse)
|
}).(model.Warehouse)
|
||||||
wh2 := upsert(db, &model.Warehouse{}, "shop_id = ? AND name = ?", shop.ID, "进口酒专库", func() any {
|
wh2 := upsert(db, &model.Warehouse{}, "shop_id = ? AND name = ?", shop.ID, "进口酒专库", func() any {
|
||||||
return &model.Warehouse{
|
return &model.Warehouse{
|
||||||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||||
Name: "进口酒专库", Location: "B栋2层恒温区", IsDefault: false,
|
Name: "进口酒专库", Location: "B栋2层恒温区", IsDefault: false,
|
||||||
}
|
}
|
||||||
}).(model.Warehouse)
|
}).(model.Warehouse)
|
||||||
|
|
||||||
@@ -259,8 +259,8 @@ func main() {
|
|||||||
// ═══════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════
|
||||||
type partnerSeed struct {
|
type partnerSeed struct {
|
||||||
code, name, ptype, contact, phone, address, bank string
|
code, name, ptype, contact, phone, address, bank string
|
||||||
creditLimit float64
|
creditLimit float64
|
||||||
remark string
|
remark string
|
||||||
}
|
}
|
||||||
partnerSeeds := []partnerSeed{
|
partnerSeeds := []partnerSeed{
|
||||||
{"SUP001", "贵州茅台酒股份有限公司", "supplier", "张经理", "0851-22222001",
|
{"SUP001", "贵州茅台酒股份有限公司", "supplier", "张经理", "0851-22222001",
|
||||||
@@ -528,22 +528,22 @@ func (s *seeder) createStockIn(
|
|||||||
full, initials := util.ToPinyin(g.name)
|
full, initials := util.ToPinyin(g.name)
|
||||||
// 铁律:入库每行 = 新建一个独立 product(唯一编号),不按名称复用
|
// 铁律:入库每行 = 新建一个独立 product(唯一编号),不按名称复用
|
||||||
p := model.Product{
|
p := model.Product{
|
||||||
TenantBase: model.TenantBase{ShopID: s.shopID},
|
TenantBase: model.TenantBase{ShopID: s.shopID},
|
||||||
PublicID: uuid.New().String(),
|
PublicID: uuid.New().String(),
|
||||||
Code: code,
|
Code: code,
|
||||||
Name: g.name,
|
Name: g.name,
|
||||||
Series: g.series,
|
Series: g.series,
|
||||||
Spec: g.spec,
|
Spec: g.spec,
|
||||||
Unit: g.unit,
|
Unit: g.unit,
|
||||||
Brand: g.brand,
|
Brand: g.brand,
|
||||||
CategoryID: g.cat,
|
CategoryID: g.cat,
|
||||||
PurchasePrice: ln.price,
|
PurchasePrice: ln.price,
|
||||||
SalePrice: g.sale,
|
SalePrice: g.sale,
|
||||||
MinStock: g.minStock,
|
MinStock: g.minStock,
|
||||||
BatchNo: ln.batch,
|
BatchNo: ln.batch,
|
||||||
ProductionDate: ln.prodDate,
|
ProductionDate: ln.prodDate,
|
||||||
NamePinyin: full,
|
NamePinyin: full,
|
||||||
NameInitials: initials,
|
NameInitials: initials,
|
||||||
}
|
}
|
||||||
s.db.Create(&p)
|
s.db.Create(&p)
|
||||||
|
|
||||||
@@ -678,8 +678,8 @@ func (s *seeder) createStockOut(
|
|||||||
Series: p.Series,
|
Series: p.Series,
|
||||||
Spec: p.Spec,
|
Spec: p.Spec,
|
||||||
Quantity: ln.qty,
|
Quantity: ln.qty,
|
||||||
UnitPrice: p.PurchasePrice, // 成本单价(快照)
|
UnitPrice: p.PurchasePrice, // 成本单价(快照)
|
||||||
SalePrice: ln.sale, // 售价
|
SalePrice: ln.sale, // 售价
|
||||||
TotalPrice: ln.qty * p.PurchasePrice, // 成本小计
|
TotalPrice: ln.qty * p.PurchasePrice, // 成本小计
|
||||||
BatchNo: p.BatchNo,
|
BatchNo: p.BatchNo,
|
||||||
Remark: ln.remark,
|
Remark: ln.remark,
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ func Load() {
|
|||||||
viper.SetDefault("server.cors_origin", "*")
|
viper.SetDefault("server.cors_origin", "*")
|
||||||
viper.SetDefault("jwt.access_expire_min", 60)
|
viper.SetDefault("jwt.access_expire_min", 60)
|
||||||
viper.SetDefault("jwt.refresh_expire_h", 168) // 7天
|
viper.SetDefault("jwt.refresh_expire_h", 168) // 7天
|
||||||
viper.SetDefault("session.limit_total", 5) // 单用户跨全部平台合计并发上限
|
viper.SetDefault("session.limit_total", 5) // 单用户跨全部平台合计并发上限
|
||||||
viper.SetDefault("session.limit_desktop", 2) // 平台闸门:>0=允许,0=禁止该平台(数值不再作分平台上限)
|
viper.SetDefault("session.limit_desktop", 2) // 平台闸门:>0=允许,0=禁止该平台(数值不再作分平台上限)
|
||||||
viper.SetDefault("session.limit_mobile", 2)
|
viper.SetDefault("session.limit_mobile", 2)
|
||||||
viper.SetDefault("session.limit_web", 2) // 默认不禁 web(官网挂着 Web 版 app);设 0 可禁
|
viper.SetDefault("session.limit_web", 2) // 默认不禁 web(官网挂着 Web 版 app);设 0 可禁
|
||||||
viper.SetDefault("session.max_failures", 5)
|
viper.SetDefault("session.max_failures", 5)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func (h *FinanceHandler) ListRecords(c *gin.Context) {
|
|||||||
|
|
||||||
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"
|
||||||
StartDate string `form:"start_date"` // YYYY-MM-DD(区间过滤,优先于 month)
|
StartDate string `form:"start_date"` // YYYY-MM-DD(区间过滤,优先于 month)
|
||||||
EndDate string `form:"end_date"`
|
EndDate string `form:"end_date"`
|
||||||
PartnerID uint64 `form:"partner_id"`
|
PartnerID uint64 `form:"partner_id"`
|
||||||
@@ -67,7 +67,7 @@ func (h *FinanceHandler) ListRecords(c *gin.Context) {
|
|||||||
|
|
||||||
records := make([]model.FinanceRecord, 0)
|
records := make([]model.FinanceRecord, 0)
|
||||||
offset := (q.Page - 1) * q.PageSize
|
offset := (q.Page - 1) * q.PageSize
|
||||||
base.Preload("Partner").Order("record_date DESC, id DESC").Offset(offset).Limit(q.PageSize).Find(&records)
|
base.Preload("Partner", "shop_id = ?", shopID).Order("record_date DESC, id DESC").Offset(offset).Limit(q.PageSize).Find(&records)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"data": records,
|
"data": records,
|
||||||
@@ -93,6 +93,11 @@ func (h *FinanceHandler) Create(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// SEC-001:往来单位必须属于当前店(防跨租户引用泄露)
|
||||||
|
if err := ensureShopRefOpt(h.db, "partners", req.PartnerID, shopID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
if req.Type != "payment" && req.Type != "receipt" {
|
if req.Type != "payment" && req.Type != "receipt" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "type must be payment or receipt"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "type must be payment or receipt"})
|
||||||
return
|
return
|
||||||
@@ -236,7 +241,7 @@ func (h *FinanceHandler) Summary(c *gin.Context) {
|
|||||||
COUNT(*) AS record_count,
|
COUNT(*) AS record_count,
|
||||||
SUM(f.amount) AS total_amount
|
SUM(f.amount) AS total_amount
|
||||||
FROM finance_records f
|
FROM finance_records f
|
||||||
LEFT JOIN partners p ON p.id = f.partner_id
|
LEFT JOIN partners p ON p.id = f.partner_id AND p.shop_id = f.shop_id
|
||||||
WHERE f.shop_id = ? AND f.deleted_at IS NULL
|
WHERE f.shop_id = ? AND f.deleted_at IS NULL
|
||||||
AND f.type IN ('payable','receivable')
|
AND f.type IN ('payable','receivable')
|
||||||
AND f.status = 'open'
|
AND f.status = 'open'
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shakinm/xlsReader/xls"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/shakinm/xlsReader/xls"
|
||||||
"github.com/xuri/excelize/v2"
|
"github.com/xuri/excelize/v2"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
@@ -336,12 +336,12 @@ func (h *ImportHandler) ImportStockIn(c *gin.Context) {
|
|||||||
total := qty * price
|
total := qty * price
|
||||||
totalAmount += total
|
totalAmount += total
|
||||||
items = append(items, model.StockInItem{
|
items = append(items, model.StockInItem{
|
||||||
ShopID: shopID,
|
ShopID: shopID,
|
||||||
ProductID: prod.ID,
|
ProductID: prod.ID,
|
||||||
Quantity: qty,
|
Quantity: qty,
|
||||||
UnitPrice: price,
|
UnitPrice: price,
|
||||||
TotalPrice: total,
|
TotalPrice: total,
|
||||||
BatchNo: batchNo,
|
BatchNo: batchNo,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,10 +439,10 @@ func (h *ImportHandler) ImportStockOut(c *gin.Context) {
|
|||||||
total := qty * price
|
total := qty * price
|
||||||
totalAmount += total
|
totalAmount += total
|
||||||
items = append(items, model.StockOutItem{
|
items = append(items, model.StockOutItem{
|
||||||
ShopID: shopID,
|
ShopID: shopID,
|
||||||
ProductID: prod.ID,
|
ProductID: prod.ID,
|
||||||
Quantity: qty,
|
Quantity: qty,
|
||||||
UnitPrice: price,
|
UnitPrice: price,
|
||||||
TotalPrice: total,
|
TotalPrice: total,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,7 +230,8 @@ func (h *InventoryHandler) Logs(c *gin.Context) {
|
|||||||
query.Count(&total)
|
query.Count(&total)
|
||||||
|
|
||||||
logs := make([]model.InventoryLog, 0)
|
logs := make([]model.InventoryLog, 0)
|
||||||
query.Preload("Product").Preload("Warehouse").Offset((page-1)*pageSize).Limit(pageSize).Order("id DESC").Find(&logs)
|
query.Preload("Product", "shop_id = ?", shopID).
|
||||||
|
Preload("Warehouse", "shop_id = ?", shopID).Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&logs)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"data": logs, "total": total, "page": page, "page_size": pageSize})
|
c.JSON(http.StatusOK, gin.H{"data": logs, "total": total, "page": page, "page_size": pageSize})
|
||||||
}
|
}
|
||||||
@@ -250,6 +251,18 @@ func (h *InventoryHandler) CreateCheck(c *gin.Context) {
|
|||||||
req.OperatorID = operatorID
|
req.OperatorID = operatorID
|
||||||
req.Status = "draft"
|
req.Status = "draft"
|
||||||
|
|
||||||
|
// SEC-P01:外键归属校验——仓库与明细商品必须属于本店,拒绝跨租户脏引用
|
||||||
|
if err := ensureShopRef(h.db, "warehouses", req.WarehouseID, shopID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range req.Items {
|
||||||
|
if err := ensureShopRef(h.db, "products", req.Items[i].ProductID, shopID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 自动填入系统库存数量(SUM 聚合)
|
// 自动填入系统库存数量(SUM 聚合)
|
||||||
for i := range req.Items {
|
for i := range req.Items {
|
||||||
req.Items[i].ShopID = shopID
|
req.Items[i].ShopID = shopID
|
||||||
@@ -274,7 +287,7 @@ func (h *InventoryHandler) CreateCheck(c *gin.Context) {
|
|||||||
func (h *InventoryHandler) GetCheck(c *gin.Context) {
|
func (h *InventoryHandler) GetCheck(c *gin.Context) {
|
||||||
shopID := middleware.GetShopID(c)
|
shopID := middleware.GetShopID(c)
|
||||||
var check model.InventoryCheck
|
var check model.InventoryCheck
|
||||||
if err := h.db.Preload("Items.Product").
|
if err := h.db.Preload("Items.Product", "shop_id = ?", shopID).
|
||||||
Where("id = ? AND shop_id = ?", c.Param("id"), shopID).
|
Where("id = ? AND shop_id = ?", c.Param("id"), shopID).
|
||||||
First(&check).Error; err != nil {
|
First(&check).Error; err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
@@ -289,7 +302,7 @@ func (h *InventoryHandler) CompleteCheck(c *gin.Context) {
|
|||||||
checkID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
checkID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
|
||||||
var check model.InventoryCheck
|
var check model.InventoryCheck
|
||||||
if err := h.db.Preload("Items.Product").
|
if err := h.db.Preload("Items.Product", "shop_id = ?", shopID).
|
||||||
Where("id = ? AND shop_id = ?", checkID, shopID).
|
Where("id = ? AND shop_id = ?", checkID, shopID).
|
||||||
First(&check).Error; err != nil {
|
First(&check).Error; err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func TestInventoryHandler_List(t *testing.T) {
|
|||||||
|
|
||||||
// 直接插入库存记录
|
// 直接插入库存记录
|
||||||
inv := model.Inventory{
|
inv := model.Inventory{
|
||||||
ShopID: shop.ID,
|
ShopID: shop.ID,
|
||||||
WarehouseID: &warehouse.ID,
|
WarehouseID: &warehouse.ID,
|
||||||
ProductID: &product.ID,
|
ProductID: &product.ID,
|
||||||
Quantity: 100,
|
Quantity: 100,
|
||||||
@@ -113,7 +113,7 @@ func TestInventoryHandler_Logs(t *testing.T) {
|
|||||||
// 创建库存流水
|
// 创建库存流水
|
||||||
opID := user.ID
|
opID := user.ID
|
||||||
db.Create(&model.InventoryLog{
|
db.Create(&model.InventoryLog{
|
||||||
ShopID: shop.ID,
|
ShopID: shop.ID,
|
||||||
WarehouseID: warehouse.ID,
|
WarehouseID: warehouse.ID,
|
||||||
ProductID: product.ID,
|
ProductID: product.ID,
|
||||||
Direction: "in",
|
Direction: "in",
|
||||||
@@ -142,7 +142,7 @@ func TestInventoryHandler_CreateCheck(t *testing.T) {
|
|||||||
|
|
||||||
// 先创建库存
|
// 先创建库存
|
||||||
db.Create(&model.Inventory{
|
db.Create(&model.Inventory{
|
||||||
ShopID: shop.ID,
|
ShopID: shop.ID,
|
||||||
WarehouseID: &warehouse.ID,
|
WarehouseID: &warehouse.ID,
|
||||||
ProductID: &product.ID,
|
ProductID: &product.ID,
|
||||||
Quantity: 50,
|
Quantity: 50,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SEC-001(跨租户信息泄露)防线:
|
||||||
|
//
|
||||||
|
// 关联对象(Partner/Warehouse/Product…)若只按外键回连、不再按 shop_id 过滤,
|
||||||
|
// 攻击者可在本店单据里塞他店 id,再从 GET 接口把他店对象(含 PII)读出来。
|
||||||
|
// 两道防线缺一不可:
|
||||||
|
// 1. 写入侧 ensureShopRef:拒绝引用不属于当前店的外键;
|
||||||
|
// 2. 读取侧 Preload/JOIN 一律附加 shop_id 条件(存量脏引用也带不出数据)。
|
||||||
|
|
||||||
|
// ensureShopRef 校验外键对象存在且属于当前店(table 为调用点硬编码常量,非用户输入)。
|
||||||
|
func ensureShopRef(db *gorm.DB, table string, id uint64, shopID uint64) error {
|
||||||
|
if id == 0 {
|
||||||
|
return fmt.Errorf("引用对象 id 无效")
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
db.Table(table).
|
||||||
|
Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID).
|
||||||
|
Count(&n)
|
||||||
|
if n == 0 {
|
||||||
|
return fmt.Errorf("引用的%s不存在", refName(table))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureShopRefOpt 可选外键(如 partner_id 指针):nil/0 视为未引用,直接通过。
|
||||||
|
func ensureShopRefOpt(db *gorm.DB, table string, id *uint64, shopID uint64) error {
|
||||||
|
if id == nil || *id == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ensureShopRef(db, table, *id, shopID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refName(table string) string {
|
||||||
|
switch table {
|
||||||
|
case "partners":
|
||||||
|
return "往来单位"
|
||||||
|
case "warehouses":
|
||||||
|
return "仓库"
|
||||||
|
case "products":
|
||||||
|
return "商品"
|
||||||
|
default:
|
||||||
|
return "对象"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,7 +75,10 @@ func (h *StockInHandler) List(c *gin.Context) {
|
|||||||
query.Count(&total)
|
query.Count(&total)
|
||||||
|
|
||||||
orders := make([]model.StockInOrder, 0)
|
orders := make([]model.StockInOrder, 0)
|
||||||
query.Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
|
query.Preload("Warehouse", "shop_id = ?", shopID).
|
||||||
|
Preload("Partner", "shop_id = ?", shopID).
|
||||||
|
Preload("Operator", "shop_id = ?", shopID).
|
||||||
|
Preload("Reviewer", "shop_id = ?", shopID).
|
||||||
Offset((page - 1) * pageSize).Limit(pageSize).
|
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||||
Order("order_date DESC, id DESC").Find(&orders)
|
Order("order_date DESC, id DESC").Find(&orders)
|
||||||
|
|
||||||
@@ -162,10 +165,27 @@ func monthBounds(now time.Time) (mStart, next, prev string) {
|
|||||||
return s.Format(f), s.AddDate(0, 1, 0).Format(f), s.AddDate(0, -1, 0).Format(f)
|
return s.Format(f), s.AddDate(0, 1, 0).Format(f), s.AddDate(0, -1, 0).Format(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary GET /api/v1/stock-in/summary —— 全店入库 KPI(守多租户)
|
// summaryBounds 依 ?window= 选统计口径:
|
||||||
|
// - rolling30:近 30 天滚动窗 [今-29d, 明),对照窗为再往前 30 天——出入库列表
|
||||||
|
// KPI 用(月初自然月全 0 的观感问题,用户拍板改滚动窗);
|
||||||
|
// - 默认:自然月 + 上月(原型/财务屏口径)。
|
||||||
|
//
|
||||||
|
// 返回 YYYY-MM-DD 串,日期串比较跨 MySQL/SQLite 可移植。
|
||||||
|
func summaryBounds(c *gin.Context, now time.Time) (curFrom, curTo, prevFrom, prevTo string) {
|
||||||
|
const f = "2006-01-02"
|
||||||
|
if c.Query("window") == "rolling30" {
|
||||||
|
d := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
|
return d.AddDate(0, 0, -29).Format(f), d.AddDate(0, 0, 1).Format(f),
|
||||||
|
d.AddDate(0, 0, -59).Format(f), d.AddDate(0, 0, -29).Format(f)
|
||||||
|
}
|
||||||
|
mStart, next, prev := monthBounds(now)
|
||||||
|
return mStart, next, prev, mStart
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary GET /api/v1/stock-in/summary —— 全店入库 KPI(守多租户;?window=rolling30 见 summaryBounds)
|
||||||
func (h *StockInHandler) Summary(c *gin.Context) {
|
func (h *StockInHandler) Summary(c *gin.Context) {
|
||||||
shopID := middleware.GetShopID(c)
|
shopID := middleware.GetShopID(c)
|
||||||
mStart, next, prev := monthBounds(time.Now())
|
curFrom, curTo, prevFrom, prevTo := summaryBounds(c, time.Now())
|
||||||
agg := func(from, to string) (int64, float64) {
|
agg := func(from, to string) (int64, float64) {
|
||||||
var r struct {
|
var r struct {
|
||||||
Cnt int64
|
Cnt int64
|
||||||
@@ -177,8 +197,8 @@ func (h *StockInHandler) Summary(c *gin.Context) {
|
|||||||
return r.Cnt, r.Amt
|
return r.Cnt, r.Amt
|
||||||
}
|
}
|
||||||
var s stockSummary
|
var s stockSummary
|
||||||
s.MonthCount, s.MonthAmount = agg(mStart, next)
|
s.MonthCount, s.MonthAmount = agg(curFrom, curTo)
|
||||||
s.LastMonthCount, s.LastMonthAmount = agg(prev, mStart)
|
s.LastMonthCount, s.LastMonthAmount = agg(prevFrom, prevTo)
|
||||||
h.db.Model(&model.StockInOrder{}).
|
h.db.Model(&model.StockInOrder{}).
|
||||||
Where("shop_id = ? AND status = ? AND deleted_at IS NULL", shopID, "pending").Count(&s.PendingCount)
|
Where("shop_id = ? AND status = ? AND deleted_at IS NULL", shopID, "pending").Count(&s.PendingCount)
|
||||||
c.JSON(http.StatusOK, s)
|
c.JSON(http.StatusOK, s)
|
||||||
@@ -188,9 +208,10 @@ func (h *StockInHandler) Summary(c *gin.Context) {
|
|||||||
func (h *StockInHandler) Get(c *gin.Context) {
|
func (h *StockInHandler) Get(c *gin.Context) {
|
||||||
shopID := middleware.GetShopID(c)
|
shopID := middleware.GetShopID(c)
|
||||||
var order model.StockInOrder
|
var order model.StockInOrder
|
||||||
if err := h.db.Preload("Items.Product").Preload("Items.Product.Origin").
|
if err := h.db.Preload("Items.Product", "shop_id = ?", shopID).Preload("Items.Product.Origin").
|
||||||
Preload("Items.Product.ShelfLife").Preload("Items.Product.Storage").
|
Preload("Items.Product.ShelfLife").Preload("Items.Product.Storage").
|
||||||
Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
|
Preload("Warehouse", "shop_id = ?", shopID).Preload("Partner", "shop_id = ?", shopID).
|
||||||
|
Preload("Operator", "shop_id = ?", shopID).Preload("Reviewer", "shop_id = ?", shopID).
|
||||||
Where("id = ? AND shop_id = ? AND deleted_at IS NULL", c.Param("id"), shopID).
|
Where("id = ? AND shop_id = ? AND deleted_at IS NULL", c.Param("id"), shopID).
|
||||||
First(&order).Error; err != nil {
|
First(&order).Error; err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
@@ -210,6 +231,15 @@ func (h *StockInHandler) Create(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SEC-001:外键必须属于当前店(防跨租户引用泄露)
|
||||||
|
if err := ensureShopRef(h.db, "warehouses", req.WarehouseID, shopID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ensureShopRefOpt(h.db, "partners", req.PartnerID, shopID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
req.ShopID = shopID
|
req.ShopID = shopID
|
||||||
req.OperatorID = operatorID
|
req.OperatorID = operatorID
|
||||||
req.Status = "draft"
|
req.Status = "draft"
|
||||||
@@ -267,6 +297,16 @@ func (h *StockInHandler) Update(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SEC-001:外键必须属于当前店(防跨租户引用泄露)
|
||||||
|
if err := ensureShopRef(h.db, "warehouses", req.WarehouseID, shopID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ensureShopRefOpt(h.db, "partners", req.PartnerID, shopID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Where("order_id = ?", order.ID).Delete(&model.StockInItem{}).Error; err != nil {
|
if err := tx.Where("order_id = ?", order.ID).Delete(&model.StockInItem{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -164,6 +164,20 @@ func TestStockInHandler_Summary(t *testing.T) {
|
|||||||
assert.Equal(t, float64(2), s["month_count"])
|
assert.Equal(t, float64(2), s["month_count"])
|
||||||
assert.Equal(t, float64(400), s["month_amount"])
|
assert.Equal(t, float64(400), s["month_amount"])
|
||||||
assert.Equal(t, float64(1), s["pending_count"])
|
assert.Equal(t, float64(1), s["pending_count"])
|
||||||
|
|
||||||
|
// rolling30 口径:40 天前的单落「前一窗」,今天的 2 笔在近 30 天窗
|
||||||
|
makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{
|
||||||
|
"warehouse_id": wh.ID,
|
||||||
|
"order_date": time.Now().AddDate(0, 0, -40).Format(time.RFC3339),
|
||||||
|
"items": []map[string]interface{}{{"product_name": "B", "quantity": 1.0, "unit_price": 50.0}},
|
||||||
|
})
|
||||||
|
w = makeRequest(r, "GET", "/api/v1/stock-in/summary?window=rolling30", token, nil)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
s = parseResponse(w)
|
||||||
|
assert.Equal(t, float64(2), s["month_count"])
|
||||||
|
assert.Equal(t, float64(400), s["month_amount"])
|
||||||
|
assert.Equal(t, float64(1), s["last_month_count"])
|
||||||
|
assert.Equal(t, float64(50), s["last_month_amount"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStockInHandler_DetailFilter(t *testing.T) {
|
func TestStockInHandler_DetailFilter(t *testing.T) {
|
||||||
@@ -801,3 +815,50 @@ func TestStockInHandler_List_FilterByStatus(t *testing.T) {
|
|||||||
resp = parseResponse(w)
|
resp = parseResponse(w)
|
||||||
assert.Equal(t, float64(2), resp["total"].(float64))
|
assert.Equal(t, float64(2), resp["total"].(float64))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SEC-001 回归:跨租户外键引用必须被拒绝;存量脏引用(他店 partner_id)
|
||||||
|
// 读取时 Preload 不得把他店对象带出。
|
||||||
|
func TestStockInHandler_CrossTenantRefs(t *testing.T) {
|
||||||
|
db := testutil.SetupTestDB()
|
||||||
|
shopA := testutil.CreateTestShop(db, "SEC1A")
|
||||||
|
shopB := testutil.CreateTestShop(db, "SEC1B")
|
||||||
|
userA := testutil.CreateTestUser(db, shopA.ID, "admin", "pass", "admin")
|
||||||
|
whA := testutil.CreateTestWarehouse(db, shopA.ID, "WA")
|
||||||
|
whB := testutil.CreateTestWarehouse(db, shopB.ID, "WB")
|
||||||
|
partnerB := &model.Partner{
|
||||||
|
TenantBase: model.TenantBase{ShopID: shopB.ID},
|
||||||
|
Name: "B店机密供应商", Type: "supplier", Phone: "13800000000"}
|
||||||
|
require.NoError(t, db.Create(partnerB).Error)
|
||||||
|
tokenA := getAuthToken(userA.ID, shopA.ID, "admin")
|
||||||
|
r := setupProtectedRouter(db)
|
||||||
|
|
||||||
|
// 1) 引用他店仓库 → 400
|
||||||
|
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", tokenA, map[string]interface{}{
|
||||||
|
"warehouse_id": whB.ID,
|
||||||
|
"order_date": time.Now().Format(time.RFC3339),
|
||||||
|
"items": []map[string]interface{}{{"product_name": "A", "quantity": 1.0, "unit_price": 10.0}},
|
||||||
|
})
|
||||||
|
require.Equal(t, http.StatusBadRequest, w.Code, "cross-shop warehouse must be rejected")
|
||||||
|
|
||||||
|
// 2) 引用他店往来单位 → 400
|
||||||
|
w = makeRequest(r, "POST", "/api/v1/stock-in/orders", tokenA, map[string]interface{}{
|
||||||
|
"warehouse_id": whA.ID,
|
||||||
|
"partner_id": partnerB.ID,
|
||||||
|
"order_date": time.Now().Format(time.RFC3339),
|
||||||
|
"items": []map[string]interface{}{{"product_name": "A", "quantity": 1.0, "unit_price": 10.0}},
|
||||||
|
})
|
||||||
|
require.Equal(t, http.StatusBadRequest, w.Code, "cross-shop partner must be rejected")
|
||||||
|
|
||||||
|
// 3) 存量脏引用:直接落库一张 A 店单指向 B 店 partner,GET 不得带出 B 店对象
|
||||||
|
dirty := &model.StockInOrder{
|
||||||
|
TenantBase: model.TenantBase{ShopID: shopA.ID},
|
||||||
|
WarehouseID: whA.ID, PartnerID: &partnerB.ID,
|
||||||
|
OrderNo: "SEC1-DIRTY", Status: "draft", OperatorID: userA.ID,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.Create(dirty).Error)
|
||||||
|
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-in/orders/%d", dirty.ID), tokenA, nil)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
body := w.Body.String()
|
||||||
|
assert.NotContains(t, body, "B店机密供应商", "cross-shop partner PII must not leak via preload")
|
||||||
|
assert.NotContains(t, body, "13800000000")
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,17 +71,20 @@ func (h *StockOutHandler) List(c *gin.Context) {
|
|||||||
query.Count(&total)
|
query.Count(&total)
|
||||||
|
|
||||||
orders := make([]model.StockOutOrder, 0)
|
orders := make([]model.StockOutOrder, 0)
|
||||||
query.Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
|
query.Preload("Warehouse", "shop_id = ?", shopID).
|
||||||
|
Preload("Partner", "shop_id = ?", shopID).
|
||||||
|
Preload("Operator", "shop_id = ?", shopID).
|
||||||
|
Preload("Reviewer", "shop_id = ?", shopID).
|
||||||
Offset((page - 1) * pageSize).Limit(pageSize).
|
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||||
Order("order_date DESC, id DESC").Find(&orders)
|
Order("order_date DESC, id DESC").Find(&orders)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"data": orders, "total": total, "page": page, "page_size": pageSize})
|
c.JSON(http.StatusOK, gin.H{"data": orders, "total": total, "page": page, "page_size": pageSize})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary GET /api/v1/stock-out/summary —— 全店出库 KPI(守多租户)
|
// Summary GET /api/v1/stock-out/summary —— 全店出库 KPI(守多租户;?window=rolling30 见 summaryBounds)
|
||||||
func (h *StockOutHandler) Summary(c *gin.Context) {
|
func (h *StockOutHandler) Summary(c *gin.Context) {
|
||||||
shopID := middleware.GetShopID(c)
|
shopID := middleware.GetShopID(c)
|
||||||
mStart, next, prev := monthBounds(time.Now())
|
curFrom, curTo, prevFrom, prevTo := summaryBounds(c, time.Now())
|
||||||
agg := func(from, to string) (int64, float64) {
|
agg := func(from, to string) (int64, float64) {
|
||||||
var r struct {
|
var r struct {
|
||||||
Cnt int64
|
Cnt int64
|
||||||
@@ -93,8 +96,8 @@ func (h *StockOutHandler) Summary(c *gin.Context) {
|
|||||||
return r.Cnt, r.Amt
|
return r.Cnt, r.Amt
|
||||||
}
|
}
|
||||||
var s stockSummary
|
var s stockSummary
|
||||||
s.MonthCount, s.MonthAmount = agg(mStart, next)
|
s.MonthCount, s.MonthAmount = agg(curFrom, curTo)
|
||||||
s.LastMonthCount, s.LastMonthAmount = agg(prev, mStart)
|
s.LastMonthCount, s.LastMonthAmount = agg(prevFrom, prevTo)
|
||||||
h.db.Model(&model.StockOutOrder{}).
|
h.db.Model(&model.StockOutOrder{}).
|
||||||
Where("shop_id = ? AND status = ? AND deleted_at IS NULL", shopID, "pending").Count(&s.PendingCount)
|
Where("shop_id = ? AND status = ? AND deleted_at IS NULL", shopID, "pending").Count(&s.PendingCount)
|
||||||
c.JSON(http.StatusOK, s)
|
c.JSON(http.StatusOK, s)
|
||||||
@@ -139,7 +142,9 @@ func (h *StockOutHandler) ConfirmSale(c *gin.Context) {
|
|||||||
func (h *StockOutHandler) Get(c *gin.Context) {
|
func (h *StockOutHandler) Get(c *gin.Context) {
|
||||||
shopID := middleware.GetShopID(c)
|
shopID := middleware.GetShopID(c)
|
||||||
var order model.StockOutOrder
|
var order model.StockOutOrder
|
||||||
if err := h.db.Preload("Items.Product").Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
|
if err := h.db.Preload("Items.Product", "shop_id = ?", shopID).
|
||||||
|
Preload("Warehouse", "shop_id = ?", shopID).Preload("Partner", "shop_id = ?", shopID).
|
||||||
|
Preload("Operator", "shop_id = ?", shopID).Preload("Reviewer", "shop_id = ?", shopID).
|
||||||
Where("id = ? AND shop_id = ? AND deleted_at IS NULL", c.Param("id"), shopID).
|
Where("id = ? AND shop_id = ? AND deleted_at IS NULL", c.Param("id"), shopID).
|
||||||
First(&order).Error; err != nil {
|
First(&order).Error; err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
@@ -148,7 +153,6 @@ func (h *StockOutHandler) Get(c *gin.Context) {
|
|||||||
util.RespondSuccess(c, order)
|
util.RespondSuccess(c, order)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Create POST /api/v1/stock-out/orders
|
// Create POST /api/v1/stock-out/orders
|
||||||
// fillStockOutItemSnapshots 按 product_id 从商品主数据拷明细快照列(编码/名称/系列/规格/批次/生产日期)。
|
// fillStockOutItemSnapshots 按 product_id 从商品主数据拷明细快照列(编码/名称/系列/规格/批次/生产日期)。
|
||||||
// 明细 = product 引用 + 快照(历史保真:商品日后改名/删除,单据仍能还原当时信息;搜索/退单提示读快照)。
|
// 明细 = product 引用 + 快照(历史保真:商品日后改名/删除,单据仍能还原当时信息;搜索/退单提示读快照)。
|
||||||
@@ -198,6 +202,15 @@ func (h *StockOutHandler) Create(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SEC-001:外键必须属于当前店(防跨租户引用泄露)
|
||||||
|
if err := ensureShopRef(h.db, "warehouses", req.WarehouseID, shopID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ensureShopRefOpt(h.db, "partners", req.PartnerID, shopID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
req.ShopID = shopID
|
req.ShopID = shopID
|
||||||
req.OperatorID = operatorID
|
req.OperatorID = operatorID
|
||||||
|
|
||||||
@@ -256,6 +269,16 @@ func (h *StockOutHandler) Update(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SEC-001:外键必须属于当前店(防跨租户引用泄露)
|
||||||
|
if err := ensureShopRef(h.db, "warehouses", req.WarehouseID, shopID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ensureShopRefOpt(h.db, "partners", req.PartnerID, shopID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Where("order_id = ?", order.ID).Delete(&model.StockOutItem{}).Error; err != nil {
|
if err := tx.Where("order_id = ?", order.ID).Delete(&model.StockOutItem{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func TestWriteAccessMatrix(t *testing.T) {
|
|||||||
type tc struct {
|
type tc struct {
|
||||||
name string
|
name string
|
||||||
role string
|
role string
|
||||||
daysExpired int // >0 已过期天数;-30 表示未过期
|
daysExpired int // >0 已过期天数;-30 表示未过期
|
||||||
wantPostFwd bool // POST 是否应放行(非 403)
|
wantPostFwd bool // POST 是否应放行(非 403)
|
||||||
wantGetFwd bool // GET 是否应放行
|
wantGetFwd bool // GET 是否应放行
|
||||||
wantCode string // 期望 403 body 的 code(空则不校验)
|
wantCode string // 期望 403 body 的 code(空则不校验)
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ import "time"
|
|||||||
|
|
||||||
// ErrorReport 客户端错误上报记录(系统级,不做多租户隔离)
|
// ErrorReport 客户端错误上报记录(系统级,不做多租户隔离)
|
||||||
type ErrorReport struct {
|
type ErrorReport struct {
|
||||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
ErrorType string `gorm:"size:30;not null;index" json:"error_type"` // flutter_error / zone_error / caught_exception
|
ErrorType string `gorm:"size:30;not null;index" json:"error_type"` // flutter_error / zone_error / caught_exception
|
||||||
AppVersion string `gorm:"size:30" json:"app_version"`
|
AppVersion string `gorm:"size:30" json:"app_version"`
|
||||||
Platform string `gorm:"size:20" json:"platform"` // ios/android/web/windows/macos
|
Platform string `gorm:"size:20" json:"platform"` // ios/android/web/windows/macos
|
||||||
Username string `gorm:"size:50;index" json:"username"`
|
Username string `gorm:"size:50;index" json:"username"`
|
||||||
ShopID uint64 `gorm:"index" json:"shop_id"`
|
ShopID uint64 `gorm:"index" json:"shop_id"`
|
||||||
ShopNo string `gorm:"size:50" json:"shop_no"`
|
ShopNo string `gorm:"size:50" json:"shop_no"`
|
||||||
Role string `gorm:"size:20" json:"role"`
|
Role string `gorm:"size:20" json:"role"`
|
||||||
ProductInfo string `gorm:"size:200" json:"product_info"`
|
ProductInfo string `gorm:"size:200" json:"product_info"`
|
||||||
ClientIP string `gorm:"size:60" json:"client_ip"`
|
ClientIP string `gorm:"size:60" json:"client_ip"`
|
||||||
ErrorMsg string `gorm:"type:text;not null" json:"error_msg"`
|
ErrorMsg string `gorm:"type:text;not null" json:"error_msg"`
|
||||||
StackTrace string `gorm:"type:text" json:"stack_trace"`
|
StackTrace string `gorm:"type:text" json:"stack_trace"`
|
||||||
OccurredAt time.Time `json:"occurred_at"`
|
OccurredAt time.Time `json:"occurred_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import "time"
|
|||||||
|
|
||||||
type License struct {
|
type License struct {
|
||||||
Base
|
Base
|
||||||
ShopID uint64 `gorm:"not null;index" json:"shop_id"`
|
ShopID uint64 `gorm:"not null;index" json:"shop_id"`
|
||||||
LicenseKey string `gorm:"size:768;uniqueIndex" json:"license_key"`
|
LicenseKey string `gorm:"size:768;uniqueIndex" json:"license_key"`
|
||||||
Type string `gorm:"type:enum('trial','monthly','annual','lifetime');default:'trial'" json:"type"`
|
Type string `gorm:"type:enum('trial','monthly','annual','lifetime');default:'trial'" json:"type"`
|
||||||
// Tier 当前权益档位(标准/pro/max…),随兑换码写入;当前默认 'standard',暂不据此做能力差异。
|
// Tier 当前权益档位(标准/pro/max…),随兑换码写入;当前默认 'standard',暂不据此做能力差异。
|
||||||
Tier string `gorm:"size:32;not null;default:'standard'" json:"tier"`
|
Tier string `gorm:"size:32;not null;default:'standard'" json:"tier"`
|
||||||
ExpiresAt *time.Time `json:"expires_at"`
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||||
MaxDevices int `gorm:"default:3" json:"max_devices"`
|
MaxDevices int `gorm:"default:3" json:"max_devices"`
|
||||||
Features JSON `gorm:"type:json" json:"features,omitempty"`
|
Features JSON `gorm:"type:json" json:"features,omitempty"`
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ package model
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type LicenseDevice struct {
|
type LicenseDevice struct {
|
||||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
LicenseID uint64 `gorm:"not null;index" json:"license_id"`
|
LicenseID uint64 `gorm:"not null;index" json:"license_id"`
|
||||||
ShopID uint64 `gorm:"not null;index" json:"shop_id"`
|
ShopID uint64 `gorm:"not null;index" json:"shop_id"`
|
||||||
DeviceID string `gorm:"size:255;not null" json:"device_id"`
|
DeviceID string `gorm:"size:255;not null" json:"device_id"`
|
||||||
DeviceName string `gorm:"size:255" json:"device_name"`
|
DeviceName string `gorm:"size:255" json:"device_name"`
|
||||||
Platform string `gorm:"size:50" json:"platform"` // windows|macos|android|ios|web
|
Platform string `gorm:"size:50" json:"platform"` // windows|macos|android|ios|web
|
||||||
ActivatedAt time.Time `gorm:"not null;autoCreateTime" json:"activated_at"`
|
ActivatedAt time.Time `gorm:"not null;autoCreateTime" json:"activated_at"`
|
||||||
LastSeenAt time.Time `gorm:"not null;autoUpdateTime" json:"last_seen_at"`
|
LastSeenAt time.Time `gorm:"not null;autoUpdateTime" json:"last_seen_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (LicenseDevice) TableName() string { return "license_devices" }
|
func (LicenseDevice) TableName() string { return "license_devices" }
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package model
|
|||||||
|
|
||||||
type Partner struct {
|
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 生成,启动回填存量
|
// 拼音搜索列(同 products):Create/Update 时由 util.ToPinyin 生成,启动回填存量
|
||||||
NamePinyin string `gorm:"size:400;index" json:"-"`
|
NamePinyin string `gorm:"size:400;index" json:"-"`
|
||||||
NameInitials string `gorm:"size:100;index" json:"-"`
|
NameInitials string `gorm:"size:100;index" json:"-"`
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ package model
|
|||||||
|
|
||||||
type ProductCategory struct {
|
type ProductCategory struct {
|
||||||
TenantBase
|
TenantBase
|
||||||
Name string `gorm:"size:100;not null" json:"name"`
|
Name string `gorm:"size:100;not null" json:"name"`
|
||||||
ParentID *uint64 `json:"parent_id"`
|
ParentID *uint64 `json:"parent_id"`
|
||||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Product struct {
|
type Product struct {
|
||||||
TenantBase
|
TenantBase
|
||||||
PublicID string `gorm:"size:36;uniqueIndex" json:"public_id"`
|
PublicID string `gorm:"size:36;uniqueIndex" json:"public_id"`
|
||||||
// Code 商品编码:同店内唯一。(shop_id, code) 联合唯一索引 uk_shop_code 由 autoMigrate 显式建(见 main.go),
|
// Code 商品编码:同店内唯一。(shop_id, code) 联合唯一索引 uk_shop_code 由 autoMigrate 显式建(见 main.go),
|
||||||
// 不在此用 tag 声明——ShopID 在共用 TenantBase 上,tag 只能建单列索引会破坏多租户隔离。
|
// 不在此用 tag 声明——ShopID 在共用 TenantBase 上,tag 只能建单列索引会破坏多租户隔离。
|
||||||
Code string `gorm:"size:50" json:"code"`
|
Code string `gorm:"size:50" json:"code"`
|
||||||
@@ -25,12 +25,12 @@ type Product struct {
|
|||||||
// 特有产品的批次属性:每个 product = 一个特有产品/序列号,生产日期/批次归此(单一来源)
|
// 特有产品的批次属性:每个 product = 一个特有产品/序列号,生产日期/批次归此(单一来源)
|
||||||
ProductionDate *Date `gorm:"type:date" json:"production_date"`
|
ProductionDate *Date `gorm:"type:date" json:"production_date"`
|
||||||
BatchNo string `gorm:"size:50" json:"batch_no"`
|
BatchNo string `gorm:"size:50" json:"batch_no"`
|
||||||
MinStock int `gorm:"default:0" json:"min_stock"`
|
MinStock int `gorm:"default:0" json:"min_stock"`
|
||||||
Description string `gorm:"type:text" json:"description"`
|
Description string `gorm:"type:text" json:"description"`
|
||||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||||
Remark string `gorm:"size:500" json:"remark"`
|
Remark string `gorm:"size:500" json:"remark"`
|
||||||
NamePinyin string `gorm:"size:400;index" json:"-"`
|
NamePinyin string `gorm:"size:400;index" json:"-"`
|
||||||
NameInitials string `gorm:"size:100;index" json:"-"`
|
NameInitials string `gorm:"size:100;index" json:"-"`
|
||||||
|
|
||||||
// 商品属性字典外键(可空,公开页展示用)
|
// 商品属性字典外键(可空,公开页展示用)
|
||||||
OriginID *uint64 `json:"origin_id"`
|
OriginID *uint64 `json:"origin_id"`
|
||||||
|
|||||||
@@ -6,21 +6,21 @@ import "time"
|
|||||||
|
|
||||||
type StockInOrder struct {
|
type StockInOrder struct {
|
||||||
TenantBase
|
TenantBase
|
||||||
OrderNo string `gorm:"size:50;uniqueIndex:uk_shop_order_no" json:"order_no"`
|
OrderNo string `gorm:"size:50;uniqueIndex:uk_shop_order_no" json:"order_no"`
|
||||||
Type string `gorm:"size:30;default:'purchase'" json:"type"`
|
Type string `gorm:"size:30;default:'purchase'" json:"type"`
|
||||||
WarehouseID uint64 `gorm:"not null" json:"warehouse_id" binding:"required,min=1"`
|
WarehouseID uint64 `gorm:"not null" json:"warehouse_id" binding:"required,min=1"`
|
||||||
PartnerID *uint64 `json:"partner_id"`
|
PartnerID *uint64 `json:"partner_id"`
|
||||||
OperatorID uint64 `gorm:"not null" json:"operator_id"`
|
OperatorID uint64 `gorm:"not null" json:"operator_id"`
|
||||||
CreatorID *uint64 `json:"creator_id"`
|
CreatorID *uint64 `json:"creator_id"`
|
||||||
ReviewerID *uint64 `json:"reviewer_id"`
|
ReviewerID *uint64 `json:"reviewer_id"`
|
||||||
Status string `gorm:"type:enum('draft','pending','approved','rejected');default:'draft'" json:"status"`
|
Status string `gorm:"type:enum('draft','pending','approved','rejected');default:'draft'" json:"status"`
|
||||||
OrderDate Date `gorm:"type:date" json:"order_date"`
|
OrderDate Date `gorm:"type:date" json:"order_date"`
|
||||||
TotalAmount float64 `gorm:"type:decimal(16,2);default:0" json:"total_amount"`
|
TotalAmount float64 `gorm:"type:decimal(16,2);default:0" json:"total_amount"`
|
||||||
ReviewedAt *time.Time `json:"reviewed_at"`
|
ReviewedAt *time.Time `json:"reviewed_at"`
|
||||||
// 退单状态:none=无 / partial=部分退单 / full=已全退(仅 approved 单可退)
|
// 退单状态:none=无 / partial=部分退单 / full=已全退(仅 approved 单可退)
|
||||||
ReturnState string `gorm:"size:20;default:'none'" json:"return_state"`
|
ReturnState string `gorm:"size:20;default:'none'" json:"return_state"`
|
||||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||||
Remark string `gorm:"size:500" json:"remark"`
|
Remark string `gorm:"size:500" json:"remark"`
|
||||||
|
|
||||||
Items []StockInItem `gorm:"foreignKey:OrderID" json:"items,omitempty"`
|
Items []StockInItem `gorm:"foreignKey:OrderID" json:"items,omitempty"`
|
||||||
Warehouse *Warehouse `gorm:"foreignKey:WarehouseID" json:"warehouse,omitempty"`
|
Warehouse *Warehouse `gorm:"foreignKey:WarehouseID" json:"warehouse,omitempty"`
|
||||||
@@ -32,25 +32,25 @@ type StockInOrder struct {
|
|||||||
|
|
||||||
type StockInItem struct {
|
type StockInItem struct {
|
||||||
Base
|
Base
|
||||||
OrderID uint64 `gorm:"not null;index" json:"order_id"`
|
OrderID uint64 `gorm:"not null;index" json:"order_id"`
|
||||||
ShopID uint64 `gorm:"not null" json:"shop_id"`
|
ShopID uint64 `gorm:"not null" json:"shop_id"`
|
||||||
ProductID uint64 `gorm:"not null" json:"product_id"`
|
ProductID uint64 `gorm:"not null" json:"product_id"`
|
||||||
// 历史导入快照列:源明细自带的商品信息,不依赖 product 主数据(product_id 可为 0)。
|
// 历史导入快照列:源明细自带的商品信息,不依赖 product 主数据(product_id 可为 0)。
|
||||||
ProductCode string `gorm:"size:50" json:"product_code"`
|
ProductCode string `gorm:"size:50" json:"product_code"`
|
||||||
ProductName string `gorm:"size:255" json:"product_name"`
|
ProductName string `gorm:"size:255" json:"product_name"`
|
||||||
Series string `gorm:"size:100" json:"series"`
|
Series string `gorm:"size:100" json:"series"`
|
||||||
Spec string `gorm:"size:100" json:"spec"`
|
Spec string `gorm:"size:100" json:"spec"`
|
||||||
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"`
|
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"`
|
||||||
UnitPrice float64 `gorm:"type:decimal(16,2);default:0" json:"unit_price"`
|
UnitPrice float64 `gorm:"type:decimal(16,2);default:0" json:"unit_price"`
|
||||||
// 建议售价:仅用于入库建产品时写入 product.SalePrice,不落 stock_in_items 表(gorm:"-")。
|
// 建议售价:仅用于入库建产品时写入 product.SalePrice,不落 stock_in_items 表(gorm:"-")。
|
||||||
SalePrice float64 `gorm:"-" json:"sale_price"`
|
SalePrice float64 `gorm:"-" json:"sale_price"`
|
||||||
TotalPrice float64 `gorm:"type:decimal(16,2);default:0" json:"total_price"`
|
TotalPrice float64 `gorm:"type:decimal(16,2);default:0" json:"total_price"`
|
||||||
// 已退数量:0=未退,=Quantity 表示整行已退单(整行退,不做部分数量)
|
// 已退数量:0=未退,=Quantity 表示整行已退单(整行退,不做部分数量)
|
||||||
ReturnedQuantity float64 `gorm:"type:decimal(12,3);default:0" json:"returned_quantity"`
|
ReturnedQuantity float64 `gorm:"type:decimal(12,3);default:0" json:"returned_quantity"`
|
||||||
BatchNo string `gorm:"size:50" json:"batch_no"`
|
BatchNo string `gorm:"size:50" json:"batch_no"`
|
||||||
ProductionDate *Date `gorm:"type:date" json:"production_date"`
|
ProductionDate *Date `gorm:"type:date" json:"production_date"`
|
||||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||||
Remark string `gorm:"size:255" json:"remark"`
|
Remark string `gorm:"size:255" json:"remark"`
|
||||||
|
|
||||||
Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||||
Order *StockInOrder `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
Order *StockInOrder `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||||
@@ -60,21 +60,21 @@ type StockInItem struct {
|
|||||||
|
|
||||||
type StockOutOrder struct {
|
type StockOutOrder struct {
|
||||||
TenantBase
|
TenantBase
|
||||||
OrderNo string `gorm:"size:50;uniqueIndex:uk_shop_order_no" json:"order_no"`
|
OrderNo string `gorm:"size:50;uniqueIndex:uk_shop_order_no" json:"order_no"`
|
||||||
Type string `gorm:"size:30;default:'sale'" json:"type"`
|
Type string `gorm:"size:30;default:'sale'" json:"type"`
|
||||||
WarehouseID uint64 `gorm:"not null" json:"warehouse_id" binding:"required,min=1"`
|
WarehouseID uint64 `gorm:"not null" json:"warehouse_id" binding:"required,min=1"`
|
||||||
PartnerID *uint64 `json:"partner_id"`
|
PartnerID *uint64 `json:"partner_id"`
|
||||||
OperatorID uint64 `gorm:"not null" json:"operator_id"`
|
OperatorID uint64 `gorm:"not null" json:"operator_id"`
|
||||||
CreatorID *uint64 `json:"creator_id"`
|
CreatorID *uint64 `json:"creator_id"`
|
||||||
ReviewerID *uint64 `json:"reviewer_id"`
|
ReviewerID *uint64 `json:"reviewer_id"`
|
||||||
Status string `gorm:"type:enum('draft','pending','approved','rejected');default:'draft'" json:"status"`
|
Status string `gorm:"type:enum('draft','pending','approved','rejected');default:'draft'" json:"status"`
|
||||||
OrderDate Date `gorm:"type:date" json:"order_date"`
|
OrderDate Date `gorm:"type:date" json:"order_date"`
|
||||||
TotalAmount float64 `gorm:"type:decimal(16,2);default:0" json:"total_amount"`
|
TotalAmount float64 `gorm:"type:decimal(16,2);default:0" json:"total_amount"`
|
||||||
ReviewedAt *time.Time `json:"reviewed_at"`
|
ReviewedAt *time.Time `json:"reviewed_at"`
|
||||||
// 退单状态:none / partial / full
|
// 退单状态:none / partial / full
|
||||||
ReturnState string `gorm:"size:20;default:'none'" json:"return_state"`
|
ReturnState string `gorm:"size:20;default:'none'" json:"return_state"`
|
||||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||||
Remark string `gorm:"size:500" json:"remark"`
|
Remark string `gorm:"size:500" json:"remark"`
|
||||||
|
|
||||||
Items []StockOutItem `gorm:"foreignKey:OrderID" json:"items,omitempty"`
|
Items []StockOutItem `gorm:"foreignKey:OrderID" json:"items,omitempty"`
|
||||||
Warehouse *Warehouse `gorm:"foreignKey:WarehouseID" json:"warehouse,omitempty"`
|
Warehouse *Warehouse `gorm:"foreignKey:WarehouseID" json:"warehouse,omitempty"`
|
||||||
@@ -86,25 +86,25 @@ type StockOutOrder struct {
|
|||||||
|
|
||||||
type StockOutItem struct {
|
type StockOutItem struct {
|
||||||
Base
|
Base
|
||||||
OrderID uint64 `gorm:"not null;index" json:"order_id"`
|
OrderID uint64 `gorm:"not null;index" json:"order_id"`
|
||||||
ShopID uint64 `gorm:"not null" json:"shop_id"`
|
ShopID uint64 `gorm:"not null" json:"shop_id"`
|
||||||
ProductID uint64 `gorm:"not null" json:"product_id"`
|
ProductID uint64 `gorm:"not null" json:"product_id"`
|
||||||
// 历史导入快照列:源明细自带的商品信息,不依赖 product 主数据(product_id 可为 0)。
|
// 历史导入快照列:源明细自带的商品信息,不依赖 product 主数据(product_id 可为 0)。
|
||||||
ProductCode string `gorm:"size:50" json:"product_code"`
|
ProductCode string `gorm:"size:50" json:"product_code"`
|
||||||
ProductName string `gorm:"size:255" json:"product_name"`
|
ProductName string `gorm:"size:255" json:"product_name"`
|
||||||
Series string `gorm:"size:100" json:"series"`
|
Series string `gorm:"size:100" json:"series"`
|
||||||
Spec string `gorm:"size:100" json:"spec"`
|
Spec string `gorm:"size:100" json:"spec"`
|
||||||
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"`
|
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"`
|
||||||
UnitPrice float64 `gorm:"type:decimal(16,2);default:0" json:"unit_price"`
|
UnitPrice float64 `gorm:"type:decimal(16,2);default:0" json:"unit_price"`
|
||||||
// 售价:出库实际销售单价(可编辑),应收账款按 售价×数量 计;与 UnitPrice(入库成本) 区分
|
// 售价:出库实际销售单价(可编辑),应收账款按 售价×数量 计;与 UnitPrice(入库成本) 区分
|
||||||
SalePrice float64 `gorm:"type:decimal(16,2);default:0" json:"sale_price"`
|
SalePrice float64 `gorm:"type:decimal(16,2);default:0" json:"sale_price"`
|
||||||
TotalPrice float64 `gorm:"type:decimal(16,2);default:0" json:"total_price"`
|
TotalPrice float64 `gorm:"type:decimal(16,2);default:0" json:"total_price"`
|
||||||
// 已退数量:0=未退,=Quantity 表示整行已退单
|
// 已退数量:0=未退,=Quantity 表示整行已退单
|
||||||
ReturnedQuantity float64 `gorm:"type:decimal(12,3);default:0" json:"returned_quantity"`
|
ReturnedQuantity float64 `gorm:"type:decimal(12,3);default:0" json:"returned_quantity"`
|
||||||
BatchNo string `gorm:"size:50" json:"batch_no"`
|
BatchNo string `gorm:"size:50" json:"batch_no"`
|
||||||
ProductionDate *Date `gorm:"type:date" json:"production_date"`
|
ProductionDate *Date `gorm:"type:date" json:"production_date"`
|
||||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||||
Remark string `gorm:"size:255" json:"remark"`
|
Remark string `gorm:"size:255" json:"remark"`
|
||||||
|
|
||||||
Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -112,30 +112,30 @@ type StockOutItem struct {
|
|||||||
// -------- 实时库存(批次模式:每条记录代表一个批次/批次) --------
|
// -------- 实时库存(批次模式:每条记录代表一个批次/批次) --------
|
||||||
|
|
||||||
type Inventory struct {
|
type Inventory struct {
|
||||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
ShopID uint64 `gorm:"not null" json:"shop_id"`
|
ShopID uint64 `gorm:"not null" json:"shop_id"`
|
||||||
WarehouseID *uint64 `json:"warehouse_id"`
|
WarehouseID *uint64 `json:"warehouse_id"`
|
||||||
ProductID *uint64 `json:"product_id"`
|
ProductID *uint64 `json:"product_id"`
|
||||||
StockInItemID *uint64 `json:"stock_in_item_id"`
|
StockInItemID *uint64 `json:"stock_in_item_id"`
|
||||||
InventoryCheckID *uint64 `json:"inventory_check_id"`
|
InventoryCheckID *uint64 `json:"inventory_check_id"`
|
||||||
Quantity float64 `gorm:"type:decimal(12,3);not null;default:0" json:"quantity"`
|
Quantity float64 `gorm:"type:decimal(12,3);not null;default:0" json:"quantity"`
|
||||||
// Snapshot fields: copied from product/warehouse/stock-in at approval time.
|
// Snapshot fields: copied from product/warehouse/stock-in at approval time.
|
||||||
// They reflect the state at the moment of stock-in and are NOT updated when the
|
// They reflect the state at the moment of stock-in and are NOT updated when the
|
||||||
// referenced product or warehouse record is later modified.
|
// referenced product or warehouse record is later modified.
|
||||||
ProductCode string `gorm:"size:50" json:"product_code"`
|
ProductCode string `gorm:"size:50" json:"product_code"`
|
||||||
ProductName string `gorm:"size:200" json:"product_name"`
|
ProductName string `gorm:"size:200" json:"product_name"`
|
||||||
Series string `gorm:"size:100" json:"series"`
|
Series string `gorm:"size:100" json:"series"`
|
||||||
Spec string `gorm:"size:100" json:"spec"`
|
Spec string `gorm:"size:100" json:"spec"`
|
||||||
Unit string `gorm:"size:20" json:"unit"`
|
Unit string `gorm:"size:20" json:"unit"`
|
||||||
WarehouseName string `gorm:"size:100" json:"warehouse_name"`
|
WarehouseName string `gorm:"size:100" json:"warehouse_name"`
|
||||||
UnitPrice *float64 `gorm:"type:decimal(16,2)" json:"unit_price"`
|
UnitPrice *float64 `gorm:"type:decimal(16,2)" json:"unit_price"`
|
||||||
ProductionDate *Date `gorm:"type:date" json:"production_date"`
|
ProductionDate *Date `gorm:"type:date" json:"production_date"`
|
||||||
BatchNo string `gorm:"size:50" json:"batch_no"`
|
BatchNo string `gorm:"size:50" json:"batch_no"`
|
||||||
SupplierName string `gorm:"size:200" json:"supplier_name"`
|
SupplierName string `gorm:"size:200" json:"supplier_name"`
|
||||||
Remark string `gorm:"size:500" json:"remark"`
|
Remark string `gorm:"size:500" json:"remark"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt *time.Time `gorm:"index" json:"-"`
|
DeletedAt *time.Time `gorm:"index" json:"-"`
|
||||||
|
|
||||||
StockInItem *StockInItem `gorm:"foreignKey:StockInItemID" json:"stock_in_item,omitempty"`
|
StockInItem *StockInItem `gorm:"foreignKey:StockInItemID" json:"stock_in_item,omitempty"`
|
||||||
Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||||
@@ -166,12 +166,12 @@ type InventoryLog struct {
|
|||||||
|
|
||||||
type InventoryCheck struct {
|
type InventoryCheck struct {
|
||||||
TenantBase
|
TenantBase
|
||||||
CheckNo string `gorm:"size:50;uniqueIndex:uk_shop_check_no" json:"check_no"`
|
CheckNo string `gorm:"size:50;uniqueIndex:uk_shop_check_no" json:"check_no"`
|
||||||
WarehouseID uint64 `gorm:"not null" json:"warehouse_id"`
|
WarehouseID uint64 `gorm:"not null" json:"warehouse_id"`
|
||||||
OperatorID uint64 `gorm:"not null" json:"operator_id"`
|
OperatorID uint64 `gorm:"not null" json:"operator_id"`
|
||||||
Status string `gorm:"type:enum('draft','completed');default:'draft'" json:"status"`
|
Status string `gorm:"type:enum('draft','completed');default:'draft'" json:"status"`
|
||||||
CheckDate Date `gorm:"type:date" json:"check_date"`
|
CheckDate Date `gorm:"type:date" json:"check_date"`
|
||||||
Remark string `gorm:"size:500" json:"remark"`
|
Remark string `gorm:"size:500" json:"remark"`
|
||||||
|
|
||||||
Items []InventoryCheckItem `gorm:"foreignKey:CheckID" json:"items,omitempty"`
|
Items []InventoryCheckItem `gorm:"foreignKey:CheckID" json:"items,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,26 +5,26 @@ import "time"
|
|||||||
// UserSession 服务端登录会话。JWT 的 sid claim 指向此表一行,
|
// UserSession 服务端登录会话。JWT 的 sid claim 指向此表一行,
|
||||||
// 用于支持登出/踢人/在线状态监控(JWT 本身无状态,无法撤销)。
|
// 用于支持登出/踢人/在线状态监控(JWT 本身无状态,无法撤销)。
|
||||||
type UserSession struct {
|
type UserSession struct {
|
||||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
ShopID uint64 `gorm:"not null;index:idx_session_shop_user" json:"shop_id"`
|
ShopID uint64 `gorm:"not null;index:idx_session_shop_user" json:"shop_id"`
|
||||||
UserID uint64 `gorm:"not null;index:idx_session_shop_user" json:"user_id"`
|
UserID uint64 `gorm:"not null;index:idx_session_shop_user" json:"user_id"`
|
||||||
SID string `gorm:"column:sid;size:64;not null;uniqueIndex" json:"sid"` // 嵌入 JWT
|
SID string `gorm:"column:sid;size:64;not null;uniqueIndex" json:"sid"` // 嵌入 JWT
|
||||||
DeviceID string `gorm:"size:255" json:"device_id"`
|
DeviceID string `gorm:"size:255" json:"device_id"`
|
||||||
DeviceName string `gorm:"size:255" json:"device_name"`
|
DeviceName string `gorm:"size:255" json:"device_name"`
|
||||||
Platform string `gorm:"size:50" json:"platform"` // windows|macos|linux|android|ios|web
|
Platform string `gorm:"size:50" json:"platform"` // windows|macos|linux|android|ios|web
|
||||||
PlatformClass string `gorm:"size:20;index" json:"platform_class"` // desktop|mobile|web
|
PlatformClass string `gorm:"size:20;index" json:"platform_class"` // desktop|mobile|web
|
||||||
IP string `gorm:"size:64" json:"ip"`
|
IP string `gorm:"size:64" json:"ip"`
|
||||||
UserAgent string `gorm:"size:512" json:"user_agent"`
|
UserAgent string `gorm:"size:512" json:"user_agent"`
|
||||||
// RefreshJTI 当前有效 refresh token 的 jti,用于「轮换 + 重用检测」:
|
// RefreshJTI 当前有效 refresh token 的 jti,用于「轮换 + 重用检测」:
|
||||||
// 每次续期轮换此值,若 refresh 携带的 jti 与之不符即判定为旧 token 重放(盗用),吊销整条会话。
|
// 每次续期轮换此值,若 refresh 携带的 jti 与之不符即判定为旧 token 重放(盗用),吊销整条会话。
|
||||||
RefreshJTI string `gorm:"column:refresh_jti;size:64" json:"-"`
|
RefreshJTI string `gorm:"column:refresh_jti;size:64" json:"-"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
// LastSeenAt 手工维护(Login 建行赋值、心跳/refresh 显式 Update)。
|
// LastSeenAt 手工维护(Login 建行赋值、心跳/refresh 显式 Update)。
|
||||||
// 故意用 autoCreateTime(建行给默认、之后不被 ORM 自动改);切勿改成 autoUpdateTime,
|
// 故意用 autoCreateTime(建行给默认、之后不被 ORM 自动改);切勿改成 autoUpdateTime,
|
||||||
// 否则 revoke/cleanup 等任意 Updates 都会把已撤销会话误刷成「刚活跃」。
|
// 否则 revoke/cleanup 等任意 Updates 都会把已撤销会话误刷成「刚活跃」。
|
||||||
LastSeenAt time.Time `gorm:"autoCreateTime" json:"last_seen_at"`
|
LastSeenAt time.Time `gorm:"autoCreateTime" json:"last_seen_at"`
|
||||||
RevokedAt *time.Time `gorm:"index" json:"revoked_at,omitempty"`
|
RevokedAt *time.Time `gorm:"index" json:"revoked_at,omitempty"`
|
||||||
RevokedReason string `gorm:"size:30" json:"revoked_reason,omitempty"` // kicked|logout|admin|disabled|reuse|pwd_reset
|
RevokedReason string `gorm:"size:30" json:"revoked_reason,omitempty"` // kicked|logout|admin|disabled|reuse|pwd_reset
|
||||||
RevokedBy *uint64 `gorm:"column:revoked_by" json:"revoked_by,omitempty"` // 吊销操作人 user_id;系统/自助吊销为 NULL
|
RevokedBy *uint64 `gorm:"column:revoked_by" json:"revoked_by,omitempty"` // 吊销操作人 user_id;系统/自助吊销为 NULL
|
||||||
RefreshExpAt time.Time `json:"refresh_exp_at"`
|
RefreshExpAt time.Time `json:"refresh_exp_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"github.com/wangjia/jiu/backend/testutil"
|
"github.com/wangjia/jiu/backend/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
func TestStockService_ApproveStockIn_Success(t *testing.T) {
|
func TestStockService_ApproveStockIn_Success(t *testing.T) {
|
||||||
db := testutil.SetupTestDB()
|
db := testutil.SetupTestDB()
|
||||||
shop := testutil.CreateTestShop(db, "STOCK001")
|
shop := testutil.CreateTestShop(db, "STOCK001")
|
||||||
@@ -30,10 +29,10 @@ func TestStockService_ApproveStockIn_Success(t *testing.T) {
|
|||||||
OrderDate: model.Date{Time: time.Now()},
|
OrderDate: model.Date{Time: time.Now()},
|
||||||
Items: []model.StockInItem{
|
Items: []model.StockInItem{
|
||||||
{
|
{
|
||||||
ShopID: shop.ID,
|
ShopID: shop.ID,
|
||||||
ProductID: product.ID,
|
ProductID: product.ID,
|
||||||
Quantity: 10,
|
Quantity: 10,
|
||||||
UnitPrice: 5.0,
|
UnitPrice: 5.0,
|
||||||
TotalPrice: 50.0,
|
TotalPrice: 50.0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -138,7 +137,7 @@ func TestStockService_ApproveStockOut_Success(t *testing.T) {
|
|||||||
|
|
||||||
// 先入库
|
// 先入库
|
||||||
inv := model.Inventory{
|
inv := model.Inventory{
|
||||||
ShopID: shop.ID,
|
ShopID: shop.ID,
|
||||||
WarehouseID: &warehouse.ID,
|
WarehouseID: &warehouse.ID,
|
||||||
ProductID: &product.ID,
|
ProductID: &product.ID,
|
||||||
Quantity: 20,
|
Quantity: 20,
|
||||||
@@ -155,10 +154,10 @@ func TestStockService_ApproveStockOut_Success(t *testing.T) {
|
|||||||
OrderDate: model.Date{Time: time.Now()},
|
OrderDate: model.Date{Time: time.Now()},
|
||||||
Items: []model.StockOutItem{
|
Items: []model.StockOutItem{
|
||||||
{
|
{
|
||||||
ShopID: shop.ID,
|
ShopID: shop.ID,
|
||||||
ProductID: product.ID,
|
ProductID: product.ID,
|
||||||
Quantity: 5,
|
Quantity: 5,
|
||||||
UnitPrice: 10.0,
|
UnitPrice: 10.0,
|
||||||
TotalPrice: 50.0,
|
TotalPrice: 50.0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -189,7 +188,7 @@ func TestStockService_ApproveStockOut_InsufficientStock(t *testing.T) {
|
|||||||
|
|
||||||
// 库存只有 3
|
// 库存只有 3
|
||||||
inv := model.Inventory{
|
inv := model.Inventory{
|
||||||
ShopID: shop.ID,
|
ShopID: shop.ID,
|
||||||
WarehouseID: &warehouse.ID,
|
WarehouseID: &warehouse.ID,
|
||||||
ProductID: &product.ID,
|
ProductID: &product.ID,
|
||||||
Quantity: 3,
|
Quantity: 3,
|
||||||
@@ -206,7 +205,7 @@ func TestStockService_ApproveStockOut_InsufficientStock(t *testing.T) {
|
|||||||
OrderDate: model.Date{Time: time.Now()},
|
OrderDate: model.Date{Time: time.Now()},
|
||||||
Items: []model.StockOutItem{
|
Items: []model.StockOutItem{
|
||||||
{
|
{
|
||||||
ShopID: shop.ID,
|
ShopID: shop.ID,
|
||||||
ProductID: product.ID,
|
ProductID: product.ID,
|
||||||
Quantity: 10,
|
Quantity: 10,
|
||||||
},
|
},
|
||||||
@@ -237,7 +236,7 @@ func TestStockService_ApproveStockOut_ProductNotInInventory(t *testing.T) {
|
|||||||
OrderDate: model.Date{Time: time.Now()},
|
OrderDate: model.Date{Time: time.Now()},
|
||||||
Items: []model.StockOutItem{
|
Items: []model.StockOutItem{
|
||||||
{
|
{
|
||||||
ShopID: shop.ID,
|
ShopID: shop.ID,
|
||||||
ProductID: product.ID,
|
ProductID: product.ID,
|
||||||
Quantity: 5,
|
Quantity: 5,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ func main() {
|
|||||||
if config.C.Server.CORSOrigin == "*" {
|
if config.C.Server.CORSOrigin == "*" {
|
||||||
log.Fatal("server.cors_origin must not be '*' in production — set it to the actual frontend origin")
|
log.Fatal("server.cors_origin must not be '*' in production — set it to the actual frontend origin")
|
||||||
}
|
}
|
||||||
|
// SEC-002:默认/空 JWT 密钥随仓库公开,任何人可自签 superadmin token。
|
||||||
|
// 漏配 JWT_SECRET 时必须拒绝启动,而不是带公开密钥上线。
|
||||||
|
if config.C.JWT.Secret == "" ||
|
||||||
|
config.C.JWT.Secret == "change-this-to-a-random-secret-in-production" {
|
||||||
|
log.Fatal("jwt.secret is empty or still the repo default — set JWT_SECRET in production")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化数据库
|
// 初始化数据库
|
||||||
|
|||||||
Reference in New Issue
Block a user