eac3e8825c
- 库存查询加规格、系列服务端筛选(后端 IN 条件,前端从 product-options API 加载全量选项) - 库存查询新增「入库时间」列,导出 Excel 同步带上 - 入库单商品明细:隐藏商品编码列,生产日期从选填折叠区移至主行常显 - 筛选弹窗重构:顶部搜索框实时过滤 + 滚动列表(ConstrainedBox maxHeight)根治溢出 - 已选项以 Chip 标签置顶展示,每个标签带 × 可单独取消 - FilterableColumnHeader 筛选图标改为常驻,不再需要 hover 才显示 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
340 lines
10 KiB
Go
340 lines
10 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"`
|
|
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 > 500 {
|
|
pageSize = 20
|
|
}
|
|
|
|
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,''), inv.product_name) LIKE ? OR COALESCE(NULLIF(p.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))
|
|
}
|
|
}
|
|
|
|
// 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
|
|
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,''), inv.product_code, '') AS product_code,
|
|
COALESCE(NULLIF(p.name,''), inv.product_name, '') AS product_name,
|
|
COALESCE(NULLIF(p.series,''), inv.series, '') AS series,
|
|
COALESCE(NULLIF(p.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) AS unit_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
|
|
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})
|
|
}
|
|
|
|
// 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})
|
|
}
|