fix(backend): 修复库存 TOCTOU 竞态 + handler 白名单更新 + 对账接口
#29 ApproveStockOut:FOR UPDATE 锁定批次后再内存汇总总量, 消除预检 SUM 与加锁之间的竞态窗口,减少一次 DB 往返 #30 partner/warehouse/user/product_attr/product_option Update 方法: - 绑定到独立 req struct,防止请求体覆盖记录 ID - 改用 db.Model.Where("shop_id=?").Updates(map) 白名单更新, 数据库层强制 shop_id 隔离约束 #31 新增 GET /api/v1/admin/reconcile:对比 inventories 当前库存 与 inventory_logs 流水净量,返回差异行,用于发现不平账异常 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -58,3 +59,91 @@ func (h *AdminHandler) ClearData(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"cleared": cleared})
|
||||
}
|
||||
|
||||
// ReconcileInventory 对账:检查 inventories 表当前库存与 inventory_logs 流水之和是否吻合。
|
||||
// 返回所有差异行,正常情况下 data 为空数组。
|
||||
// 支持可选 ?shop_id=N 参数(superadmin 可指定任意门店)。
|
||||
func (h *AdminHandler) ReconcileInventory(c *gin.Context) {
|
||||
// superadmin 路由,可通过参数指定 shop;不指定则对所有门店执行
|
||||
var shopFilter *uint64
|
||||
if s := c.Query("shop_id"); s != "" {
|
||||
v, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid shop_id"})
|
||||
return
|
||||
}
|
||||
shopFilter = &v
|
||||
}
|
||||
_ = middleware.GetShopID // superadmin 不依赖 JWT shop_id
|
||||
|
||||
type row struct {
|
||||
ShopID uint64 `json:"shop_id"`
|
||||
WarehouseID uint64 `json:"warehouse_id"`
|
||||
ProductID uint64 `json:"product_id"`
|
||||
InvQty float64 `json:"inv_qty"`
|
||||
LogQty float64 `json:"log_qty"`
|
||||
Diff float64 `json:"diff"`
|
||||
}
|
||||
|
||||
// inventories 侧:当前各(shop,warehouse,product)库存总量
|
||||
invQuery := h.db.Table("inventories").
|
||||
Select("shop_id, warehouse_id, product_id, COALESCE(SUM(quantity),0) AS inv_qty").
|
||||
Where("deleted_at IS NULL").
|
||||
Group("shop_id, warehouse_id, product_id")
|
||||
if shopFilter != nil {
|
||||
invQuery = invQuery.Where("shop_id = ?", *shopFilter)
|
||||
}
|
||||
|
||||
// inventory_logs 侧:同维度流水净量(in 为正,out 为负)
|
||||
logQuery := h.db.Table("inventory_logs").
|
||||
Select("shop_id, warehouse_id, product_id, " +
|
||||
"COALESCE(SUM(CASE WHEN direction='in' THEN quantity ELSE -quantity END),0) AS log_qty").
|
||||
Group("shop_id, warehouse_id, product_id")
|
||||
if shopFilter != nil {
|
||||
logQuery = logQuery.Where("shop_id = ?", *shopFilter)
|
||||
}
|
||||
|
||||
// 全外连接找差异
|
||||
type invRow struct {
|
||||
ShopID uint64 `gorm:"column:shop_id"`
|
||||
WarehouseID uint64 `gorm:"column:warehouse_id"`
|
||||
ProductID uint64 `gorm:"column:product_id"`
|
||||
InvQty float64 `gorm:"column:inv_qty"`
|
||||
}
|
||||
type logRow struct {
|
||||
ShopID uint64 `gorm:"column:shop_id"`
|
||||
WarehouseID uint64 `gorm:"column:warehouse_id"`
|
||||
ProductID uint64 `gorm:"column:product_id"`
|
||||
LogQty float64 `gorm:"column:log_qty"`
|
||||
}
|
||||
|
||||
var invRows []invRow
|
||||
var logRows []logRow
|
||||
invQuery.Scan(&invRows)
|
||||
logQuery.Scan(&logRows)
|
||||
|
||||
// 合并为 map 再求差
|
||||
type key struct{ S, W, P uint64 }
|
||||
m := map[key]*row{}
|
||||
for _, r := range invRows {
|
||||
k := key{r.ShopID, r.WarehouseID, r.ProductID}
|
||||
m[k] = &row{ShopID: r.ShopID, WarehouseID: r.WarehouseID, ProductID: r.ProductID, InvQty: r.InvQty}
|
||||
}
|
||||
for _, r := range logRows {
|
||||
k := key{r.ShopID, r.WarehouseID, r.ProductID}
|
||||
if _, ok := m[k]; !ok {
|
||||
m[k] = &row{ShopID: r.ShopID, WarehouseID: r.WarehouseID, ProductID: r.ProductID}
|
||||
}
|
||||
m[k].LogQty = r.LogQty
|
||||
}
|
||||
|
||||
diffs := make([]row, 0)
|
||||
for _, v := range m {
|
||||
v.Diff = v.InvQty - v.LogQty
|
||||
if v.Diff < -0.001 || v.Diff > 0.001 {
|
||||
diffs = append(diffs, *v)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": diffs, "total": len(diffs)})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user