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:
@@ -71,17 +71,20 @@ func (h *StockOutHandler) List(c *gin.Context) {
|
||||
query.Count(&total)
|
||||
|
||||
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).
|
||||
Order("order_date DESC, id DESC").Find(&orders)
|
||||
|
||||
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) {
|
||||
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
|
||||
@@ -93,8 +96,8 @@ func (h *StockOutHandler) 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.StockOutOrder{}).
|
||||
Where("shop_id = ? AND status = ? AND deleted_at IS NULL", shopID, "pending").Count(&s.PendingCount)
|
||||
c.JSON(http.StatusOK, s)
|
||||
@@ -139,7 +142,9 @@ func (h *StockOutHandler) ConfirmSale(c *gin.Context) {
|
||||
func (h *StockOutHandler) Get(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
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).
|
||||
First(&order).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
@@ -148,7 +153,6 @@ func (h *StockOutHandler) Get(c *gin.Context) {
|
||||
util.RespondSuccess(c, order)
|
||||
}
|
||||
|
||||
|
||||
// Create POST /api/v1/stock-out/orders
|
||||
// fillStockOutItemSnapshots 按 product_id 从商品主数据拷明细快照列(编码/名称/系列/规格/批次/生产日期)。
|
||||
// 明细 = product 引用 + 快照(历史保真:商品日后改名/删除,单据仍能还原当时信息;搜索/退单提示读快照)。
|
||||
@@ -198,6 +202,15 @@ func (h *StockOutHandler) 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
|
||||
|
||||
@@ -256,6 +269,16 @@ func (h *StockOutHandler) 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.StockOutItem{}).Error; err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user