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:
@@ -75,7 +75,10 @@ func (h *StockInHandler) List(c *gin.Context) {
|
||||
query.Count(&total)
|
||||
|
||||
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).
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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) {
|
||||
var r struct {
|
||||
Cnt int64
|
||||
@@ -177,8 +197,8 @@ func (h *StockInHandler) Summary(c *gin.Context) {
|
||||
return r.Cnt, r.Amt
|
||||
}
|
||||
var s stockSummary
|
||||
s.MonthCount, s.MonthAmount = agg(mStart, next)
|
||||
s.LastMonthCount, s.LastMonthAmount = agg(prev, mStart)
|
||||
s.MonthCount, s.MonthAmount = agg(curFrom, curTo)
|
||||
s.LastMonthCount, s.LastMonthAmount = agg(prevFrom, prevTo)
|
||||
h.db.Model(&model.StockInOrder{}).
|
||||
Where("shop_id = ? AND status = ? AND deleted_at IS NULL", shopID, "pending").Count(&s.PendingCount)
|
||||
c.JSON(http.StatusOK, s)
|
||||
@@ -188,9 +208,10 @@ func (h *StockInHandler) Summary(c *gin.Context) {
|
||||
func (h *StockInHandler) Get(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
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("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).
|
||||
First(&order).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
@@ -210,6 +231,15 @@ func (h *StockInHandler) Create(c *gin.Context) {
|
||||
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.OperatorID = operatorID
|
||||
req.Status = "draft"
|
||||
@@ -267,6 +297,16 @@ func (h *StockInHandler) Update(c *gin.Context) {
|
||||
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 {
|
||||
if err := tx.Where("order_id = ?", order.ID).Delete(&model.StockInItem{}).Error; err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user