8d3f902f3b
照原型 inventory.html 把库存屏从「3 tab」重排为单视图: - 去 3 tab(库存查询/预警/流水)→ 单视图;预警并入 KPI 缺货卡点击筛选;流水移商品详情 - 头部:库存管理 + 共 N 个 SKU·数据实时 + 列设置/导出(盘点/新增入库去掉,走专门菜单) - KPI 4 卡走全店汇总接口:SKU总数/库存货值(Σqty×进价)/在库数量/缺货预警(可点筛选) - 表格 12 列:加 库存(低库存染色)/成本价(进价)/单价(售价);操作列只「查看详情」;状态圆点 - 仓库/备注默认隐藏;工具栏 搜索+状态筛选+重置 - 后端 GET /inventory/summary 全店汇总(守 shop_id);前端 model/repository/provider 配套 - golden_harness:monospace 指向 Noto(消等宽数字黑块,全 golden 受益);库存屏存量 grey 清零 后端编译/库存测试通过;前端 analyze 0 error,flutter test 242 通过;代码端颜色闸 0 违规。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
382 lines
12 KiB
Go
382 lines
12 KiB
Go
package handler
|
||
|
||
import (
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
|
||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||
"github.com/wangjia/jiu/backend/internal/model"
|
||
"github.com/wangjia/jiu/backend/internal/util"
|
||
)
|
||
|
||
type InventoryHandler struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
func NewInventoryHandler(db *gorm.DB) *InventoryHandler {
|
||
return &InventoryHandler{db: db}
|
||
}
|
||
|
||
// inventoryRow is the response shape for GET /api/v1/inventory
|
||
type inventoryRow struct {
|
||
ID uint64 `json:"id"`
|
||
ShopID uint64 `json:"shop_id"`
|
||
WarehouseID *uint64 `json:"warehouse_id"`
|
||
ProductID *uint64 `json:"product_id"`
|
||
StockInItemID *uint64 `json:"stock_in_item_id"`
|
||
Quantity float64 `json:"quantity"`
|
||
ProductCode string `json:"product_code"`
|
||
ProductName string `json:"product_name"`
|
||
Series string `json:"series"`
|
||
Spec string `json:"spec"`
|
||
Unit string `json:"unit"`
|
||
WarehouseName string `json:"warehouse_name"`
|
||
UnitPrice *float64 `json:"unit_price"` // 成本价(入库进价)
|
||
SalePrice *float64 `json:"sale_price"` // 单价(product 售价)
|
||
ProductionDate *string `json:"production_date"`
|
||
BatchNo string `json:"batch_no"`
|
||
SupplierName string `json:"supplier_name"`
|
||
Remark string `json:"remark"`
|
||
Brand string `json:"brand"`
|
||
MinStock *int `json:"min_stock"`
|
||
CreatedAt string `json:"created_at"`
|
||
}
|
||
|
||
// List GET /api/v1/inventory
|
||
func (h *InventoryHandler) List(c *gin.Context) {
|
||
shopID := middleware.GetShopID(c)
|
||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if pageSize < 1 {
|
||
pageSize = 20
|
||
}
|
||
// 超出上限钳到上限(此前是回退默认 20,导致出库选货请求大 page_size 被打回只剩 20 条)
|
||
if pageSize > 5000 {
|
||
pageSize = 5000
|
||
}
|
||
|
||
baseWhere := "inv.shop_id = ? AND inv.deleted_at IS NULL"
|
||
args := []interface{}{shopID}
|
||
|
||
keyword := c.Query("keyword")
|
||
warehouseIDStr := c.Query("warehouse_id")
|
||
inStock := c.Query("in_stock")
|
||
seriesStr := c.Query("series")
|
||
specStr := c.Query("spec")
|
||
|
||
if keyword != "" {
|
||
baseWhere += " AND (COALESCE(NULLIF(p.name,''), NULLIF(sii.product_name,''), inv.product_name) LIKE ? OR COALESCE(NULLIF(p.code,''), NULLIF(sii.product_code,''), inv.product_code) LIKE ? OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?)"
|
||
like := "%" + keyword + "%"
|
||
args = append(args, like, like, like, like)
|
||
}
|
||
if warehouseIDStr != "" {
|
||
baseWhere += " AND inv.warehouse_id = ?"
|
||
args = append(args, warehouseIDStr)
|
||
}
|
||
if inStock == "1" {
|
||
baseWhere += " AND inv.quantity > 0"
|
||
}
|
||
if seriesStr != "" {
|
||
parts := strings.Split(seriesStr, ",")
|
||
placeholders := strings.Repeat("?,", len(parts))
|
||
baseWhere += " AND COALESCE(NULLIF(p.series,''), inv.series, '') IN (" + placeholders[:len(placeholders)-1] + ")"
|
||
for _, s := range parts {
|
||
args = append(args, strings.TrimSpace(s))
|
||
}
|
||
}
|
||
if specStr != "" {
|
||
parts := strings.Split(specStr, ",")
|
||
placeholders := strings.Repeat("?,", len(parts))
|
||
baseWhere += " AND COALESCE(NULLIF(p.spec,''), inv.spec, '') IN (" + placeholders[:len(placeholders)-1] + ")"
|
||
for _, s := range parts {
|
||
args = append(args, strings.TrimSpace(s))
|
||
}
|
||
}
|
||
// 按商品过滤(商品详情:当前库存 / 货值 / 各仓库分布)
|
||
if productIDStr := c.Query("product_id"); productIDStr != "" {
|
||
baseWhere += " AND inv.product_id = ?"
|
||
args = append(args, productIDStr)
|
||
}
|
||
|
||
// Count query
|
||
countSQL := `
|
||
SELECT COUNT(*)
|
||
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 warehouses w ON w.id = inv.warehouse_id
|
||
WHERE ` + baseWhere
|
||
|
||
var total int64
|
||
if err := h.db.Raw(countSQL, args...).Scan(&total).Error; err != nil {
|
||
util.RespondError(c, http.StatusInternalServerError, "QUERY_ERROR", "查询失败")
|
||
return
|
||
}
|
||
|
||
// Data query
|
||
dataSQL := `
|
||
SELECT
|
||
inv.id, inv.shop_id, inv.warehouse_id, inv.product_id, inv.stock_in_item_id,
|
||
inv.quantity,
|
||
COALESCE(NULLIF(p.code,''), NULLIF(sii.product_code,''), inv.product_code, '') AS product_code,
|
||
COALESCE(NULLIF(p.name,''), NULLIF(sii.product_name,''), inv.product_name, '') AS product_name,
|
||
COALESCE(NULLIF(p.series,''), NULLIF(sii.series,''), inv.series, '') AS series,
|
||
COALESCE(NULLIF(p.spec,''), NULLIF(sii.spec,''), inv.spec, '') AS spec,
|
||
COALESCE(NULLIF(p.unit,''), inv.unit, '') AS unit,
|
||
COALESCE(NULLIF(w.name,''), inv.warehouse_name, '') AS warehouse_name,
|
||
COALESCE(sii.unit_price, inv.unit_price, p.purchase_price) AS unit_price,
|
||
p.sale_price AS sale_price,
|
||
COALESCE(DATE(sii.production_date), DATE(inv.production_date)) AS production_date,
|
||
COALESCE(NULLIF(sii.batch_no,''), inv.batch_no, '') AS batch_no,
|
||
inv.supplier_name,
|
||
inv.remark,
|
||
COALESCE(p.brand, '') AS brand,
|
||
p.min_stock,
|
||
inv.created_at
|
||
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 warehouses w ON w.id = inv.warehouse_id
|
||
WHERE ` + baseWhere + `
|
||
ORDER BY inv.id DESC
|
||
LIMIT ? OFFSET ?`
|
||
|
||
dataArgs := append(args, pageSize, (page-1)*pageSize)
|
||
|
||
var rows []inventoryRow
|
||
if err := h.db.Raw(dataSQL, dataArgs...).Scan(&rows).Error; err != nil {
|
||
util.RespondError(c, http.StatusInternalServerError, "QUERY_ERROR", "查询失败")
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{"data": rows, "total": total, "page": page, "page_size": pageSize})
|
||
}
|
||
|
||
// inventorySummary 全店库存 KPI 汇总(不随分页,供库存屏 KPI 卡)
|
||
type inventorySummary struct {
|
||
SkuCount int64 `json:"sku_count"` // SKU 总数(库存行数)
|
||
StockValue float64 `json:"stock_value"` // 库存货值 Σ(qty×进价)
|
||
InStockQty float64 `json:"in_stock_qty"` // 在库总数量
|
||
ShortageCount int64 `json:"shortage_count"` // 缺货数(qty<=0)
|
||
WarningCount int64 `json:"warning_count"` // 预警数(0<qty<安全库存)
|
||
}
|
||
|
||
// Summary GET /api/v1/inventory/summary —— 全店汇总(守多租户)
|
||
func (h *InventoryHandler) Summary(c *gin.Context) {
|
||
shopID := middleware.GetShopID(c)
|
||
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
|
||
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
|
||
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 {
|
||
util.RespondError(c, http.StatusInternalServerError, "QUERY_ERROR", "查询失败")
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, s)
|
||
}
|
||
|
||
// Logs GET /api/v1/inventory/logs
|
||
func (h *InventoryHandler) Logs(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, 500)
|
||
|
||
query := h.db.Model(&model.InventoryLog{}).Where("shop_id = ?", shopID)
|
||
|
||
if productID := c.Query("product_id"); productID != "" {
|
||
query = query.Where("product_id = ?", productID)
|
||
}
|
||
|
||
var total int64
|
||
query.Count(&total)
|
||
|
||
logs := make([]model.InventoryLog, 0)
|
||
query.Preload("Product").Preload("Warehouse").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})
|
||
}
|
||
|
||
// CreateCheck POST /api/v1/inventory/checks
|
||
func (h *InventoryHandler) CreateCheck(c *gin.Context) {
|
||
shopID := middleware.GetShopID(c)
|
||
operatorID := middleware.GetUserID(c)
|
||
|
||
var req model.InventoryCheck
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
req.ShopID = shopID
|
||
req.OperatorID = operatorID
|
||
req.Status = "draft"
|
||
|
||
// 自动填入系统库存数量(SUM 聚合)
|
||
for i := range req.Items {
|
||
req.Items[i].ShopID = shopID
|
||
warehouseID := req.WarehouseID
|
||
productID := req.Items[i].ProductID
|
||
var systemQty float64
|
||
h.db.Model(&model.Inventory{}).
|
||
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL",
|
||
shopID, warehouseID, productID).
|
||
Select("COALESCE(SUM(quantity), 0)").Scan(&systemQty)
|
||
req.Items[i].SystemQty = systemQty
|
||
}
|
||
|
||
if err := h.db.Create(&req).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
util.RespondCreated(c, req)
|
||
}
|
||
|
||
// GetCheck GET /api/v1/inventory/checks/:id
|
||
func (h *InventoryHandler) GetCheck(c *gin.Context) {
|
||
shopID := middleware.GetShopID(c)
|
||
var check model.InventoryCheck
|
||
if err := h.db.Preload("Items.Product").
|
||
Where("id = ? AND shop_id = ?", c.Param("id"), shopID).
|
||
First(&check).Error; err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||
return
|
||
}
|
||
util.RespondSuccess(c, check)
|
||
}
|
||
|
||
// CompleteCheck PUT /api/v1/inventory/checks/:id/complete
|
||
func (h *InventoryHandler) CompleteCheck(c *gin.Context) {
|
||
shopID := middleware.GetShopID(c)
|
||
checkID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||
|
||
var check model.InventoryCheck
|
||
if err := h.db.Preload("Items.Product").
|
||
Where("id = ? AND shop_id = ?", checkID, shopID).
|
||
First(&check).Error; err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||
return
|
||
}
|
||
if check.Status == "completed" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "已完成"})
|
||
return
|
||
}
|
||
|
||
tx := h.db.Begin()
|
||
now := time.Now()
|
||
|
||
for _, item := range check.Items {
|
||
diff := item.ActualQty - item.SystemQty
|
||
if diff == 0 {
|
||
continue
|
||
}
|
||
|
||
if diff > 0 {
|
||
// 盘盈:新增一条库存批次记录
|
||
checkIDCopy := checkID
|
||
productIDCopy := item.ProductID
|
||
warehouseIDCopy := check.WarehouseID
|
||
|
||
productCode := ""
|
||
productName := ""
|
||
series := ""
|
||
spec := ""
|
||
unit := ""
|
||
if item.Product != nil {
|
||
productCode = item.Product.Code
|
||
productName = item.Product.Name
|
||
series = item.Product.Series
|
||
spec = item.Product.Spec
|
||
unit = item.Product.Unit
|
||
}
|
||
|
||
inv := model.Inventory{
|
||
ShopID: shopID,
|
||
WarehouseID: &warehouseIDCopy,
|
||
ProductID: &productIDCopy,
|
||
InventoryCheckID: &checkIDCopy,
|
||
Quantity: diff,
|
||
ProductCode: productCode,
|
||
ProductName: productName,
|
||
Series: series,
|
||
Spec: spec,
|
||
Unit: unit,
|
||
}
|
||
tx.Create(&inv)
|
||
} else {
|
||
// 盘亏:FIFO 扣减
|
||
remaining := -diff
|
||
var batches []model.Inventory
|
||
tx.Set("gorm:query_option", "FOR UPDATE").
|
||
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL",
|
||
shopID, check.WarehouseID, item.ProductID).
|
||
Order("created_at ASC").Find(&batches)
|
||
for i := range batches {
|
||
if remaining <= 0 {
|
||
break
|
||
}
|
||
b := &batches[i]
|
||
if b.Quantity <= remaining {
|
||
remaining -= b.Quantity
|
||
tx.Model(b).Updates(map[string]interface{}{"quantity": 0, "deleted_at": now})
|
||
} else {
|
||
tx.Model(b).Update("quantity", gorm.Expr("quantity - ?", remaining))
|
||
remaining = 0
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
tx.Model(&check).Update("status", "completed")
|
||
if err := tx.Commit().Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"message": "盘点完成"})
|
||
}
|
||
|
||
// UpdateRemark PUT /api/v1/inventory/:id/remark
|
||
func (h *InventoryHandler) UpdateRemark(c *gin.Context) {
|
||
shopID := middleware.GetShopID(c)
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||
return
|
||
}
|
||
var req struct {
|
||
Remark string `json:"remark"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
result := h.db.Model(&model.Inventory{}).
|
||
Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID).
|
||
Update("remark", req.Remark)
|
||
if result.Error != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
|
||
return
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "库存记录不存在"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||
}
|