feat: 财务结清、酒行信息、库存备注编辑、标签溯源、入库必填校验
后端 - 新增 shop handler:GET/PUT /shop/info(管理员权限) - 新增 finance CloseByRef:按单据 ref_type+ref_id 结清账款 - 新增 inventory UpdateRemark:PUT /inventory/:id/remark - 入库/出库审批自动生成财务应付/应收记录(去除金额>0限制) - 种子数据 S001-S003 补充真实门店信息 前端 - 设置页新增「酒行信息」Tab,管理员可编辑门店名称/地址/电话/负责人 - 入库单列表新增结清按钮(含确认弹窗),出库单同步 - 入库表单:规格、系列、生产日期、供应商、商品名称改为提交必填 - 入库/出库列表新增入库时间、出库时间、创建时间列 - 商品标签标题改为读取 shop 表门店名,扫码文案改为「扫码溯源 · TRACE」 - 标签页脚显示门店地址和电话(从 API 读取,不再依赖编译时 dart-define) - 库存备注支持点击编辑,超4字截断显示+Hover展示全文 - ApiClient 新增 patch() 方法(已改用 PUT 规避 CORS) 文档 - 新增 docs/user-manual.md 完整用户操作手册(12章) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -20,33 +20,103 @@ 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
|
||||
}
|
||||
|
||||
query := h.db.Model(&model.Inventory{}).Where("shop_id = ?", shopID)
|
||||
baseWhere := "inv.shop_id = ? AND inv.deleted_at IS NULL"
|
||||
args := []interface{}{shopID}
|
||||
|
||||
if warehouseID := c.Query("warehouse_id"); warehouseID != "" {
|
||||
query = query.Where("warehouse_id = ?", warehouseID)
|
||||
keyword := c.Query("keyword")
|
||||
warehouseIDStr := c.Query("warehouse_id")
|
||||
|
||||
if keyword != "" {
|
||||
baseWhere += " AND (COALESCE(NULLIF(p.name,''), inv.product_name) LIKE ? OR COALESCE(NULLIF(p.code,''), inv.product_code) LIKE ?)"
|
||||
like := "%" + keyword + "%"
|
||||
args = append(args, like, like)
|
||||
}
|
||||
if productID := c.Query("product_id"); productID != "" {
|
||||
query = query.Where("product_id = ?", productID)
|
||||
}
|
||||
if c.Query("in_stock") == "1" {
|
||||
query = query.Where("quantity > 0")
|
||||
if warehouseIDStr != "" {
|
||||
baseWhere += " AND inv.warehouse_id = ?"
|
||||
args = append(args, warehouseIDStr)
|
||||
}
|
||||
|
||||
// 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
|
||||
query.Count(&total)
|
||||
h.db.Raw(countSQL, args...).Scan(&total)
|
||||
|
||||
var inventory []model.Inventory
|
||||
query.Preload("Product").Preload("Warehouse").
|
||||
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||
Find(&inventory)
|
||||
// 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_FORMAT(sii.production_date,'%Y-%m-%d'), DATE_FORMAT(inv.production_date,'%Y-%m-%d')) 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,
|
||||
DATE_FORMAT(inv.created_at, '%Y-%m-%dT%H:%i:%sZ') AS 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 ?`
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": inventory, "total": total, "page": page, "page_size": pageSize})
|
||||
dataArgs := append(args, pageSize, (page-1)*pageSize)
|
||||
|
||||
var rows []inventoryRow
|
||||
h.db.Raw(dataSQL, dataArgs...).Scan(&rows)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": rows, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
// Logs GET /api/v1/inventory/logs
|
||||
@@ -70,115 +140,6 @@ func (h *InventoryHandler) Logs(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": logs, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
// productTrackingItem is the response shape for GET /api/v1/inventory/products
|
||||
type productTrackingItem struct {
|
||||
model.StockInItem
|
||||
CurrentQty float64 `json:"current_qty"`
|
||||
Status string `json:"status"` // in_stock | sold_out
|
||||
BuyerName string `json:"buyer_name,omitempty"`
|
||||
SoldAt string `json:"sold_at,omitempty"`
|
||||
}
|
||||
|
||||
// Products GET /api/v1/inventory/products — 商品追踪:已审核入库单的明细行,附库存状态和买家
|
||||
func (h *InventoryHandler) Products(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
query := h.db.Model(&model.StockInItem{}).
|
||||
Joins("JOIN stock_in_orders ON stock_in_orders.id = stock_in_items.order_id").
|
||||
Where("stock_in_orders.shop_id = ? AND stock_in_orders.status = 'approved'", shopID)
|
||||
|
||||
if productID := c.Query("product_id"); productID != "" {
|
||||
query = query.Where("stock_in_items.product_id = ?", productID)
|
||||
}
|
||||
if warehouseID := c.Query("warehouse_id"); warehouseID != "" {
|
||||
query = query.Where("stock_in_orders.warehouse_id = ?", warehouseID)
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var items []model.StockInItem
|
||||
query.
|
||||
Preload("Product").
|
||||
Preload("Order", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Preload("Warehouse").Preload("Partner")
|
||||
}).
|
||||
Select("stock_in_items.*").
|
||||
Order("stock_in_orders.order_date DESC, stock_in_items.id DESC").
|
||||
Offset((page-1)*pageSize).Limit(pageSize).
|
||||
Find(&items)
|
||||
|
||||
// 一次查出所有库存,构建 product+warehouse → qty 的 map
|
||||
var inventories []model.Inventory
|
||||
h.db.Where("shop_id = ?", shopID).Find(&inventories)
|
||||
invMap := make(map[string]float64, len(inventories))
|
||||
for _, inv := range inventories {
|
||||
key := fmt.Sprintf("%d:%d", inv.ProductID, inv.WarehouseID)
|
||||
invMap[key] = inv.Quantity
|
||||
}
|
||||
|
||||
// 查询每个 product+warehouse 最新的出库买家信息
|
||||
type soldRow struct {
|
||||
ProductID uint64 `gorm:"column:product_id"`
|
||||
WarehouseID uint64 `gorm:"column:warehouse_id"`
|
||||
BuyerName string `gorm:"column:buyer_name"`
|
||||
SoldAt string `gorm:"column:sold_at"`
|
||||
}
|
||||
var soldRows []soldRow
|
||||
h.db.Raw(`
|
||||
SELECT sooi.product_id, soo.warehouse_id,
|
||||
COALESCE(p.name, '') AS buyer_name,
|
||||
CAST(MAX(soo.order_date) AS CHAR) AS sold_at
|
||||
FROM stock_out_orders soo
|
||||
JOIN stock_out_items sooi ON sooi.order_id = soo.id
|
||||
LEFT JOIN partners p ON p.id = soo.partner_id AND p.shop_id = ?
|
||||
WHERE soo.shop_id = ? AND soo.status = 'approved'
|
||||
GROUP BY sooi.product_id, soo.warehouse_id
|
||||
`, shopID, shopID).Scan(&soldRows)
|
||||
soldMap := make(map[string]soldRow, len(soldRows))
|
||||
for _, r := range soldRows {
|
||||
key := fmt.Sprintf("%d:%d", r.ProductID, r.WarehouseID)
|
||||
soldMap[key] = r
|
||||
}
|
||||
|
||||
result := make([]productTrackingItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
var warehouseID uint64
|
||||
if item.Order != nil {
|
||||
warehouseID = item.Order.WarehouseID
|
||||
}
|
||||
key := fmt.Sprintf("%d:%d", item.ProductID, warehouseID)
|
||||
currentQty := invMap[key]
|
||||
|
||||
status := "in_stock"
|
||||
buyerName := ""
|
||||
soldAt := ""
|
||||
if currentQty <= 0 {
|
||||
status = "sold_out"
|
||||
if si, ok := soldMap[key]; ok {
|
||||
buyerName = si.BuyerName
|
||||
if len(si.SoldAt) > 10 {
|
||||
soldAt = si.SoldAt[:10]
|
||||
} else {
|
||||
soldAt = si.SoldAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, productTrackingItem{
|
||||
StockInItem: item,
|
||||
CurrentQty: currentQty,
|
||||
Status: status,
|
||||
BuyerName: buyerName,
|
||||
SoldAt: soldAt,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": result, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
// CreateCheck POST /api/v1/inventory/checks
|
||||
func (h *InventoryHandler) CreateCheck(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
@@ -194,14 +155,17 @@ func (h *InventoryHandler) CreateCheck(c *gin.Context) {
|
||||
req.OperatorID = operatorID
|
||||
req.Status = "draft"
|
||||
|
||||
// 自动填入系统库存数量
|
||||
// 自动填入系统库存数量(SUM 聚合)
|
||||
for i := range req.Items {
|
||||
req.Items[i].ShopID = shopID
|
||||
var inv model.Inventory
|
||||
if err := h.db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
shopID, req.WarehouseID, req.Items[i].ProductID).First(&inv).Error; err == nil {
|
||||
req.Items[i].SystemQty = inv.Quantity
|
||||
}
|
||||
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 {
|
||||
@@ -223,3 +187,122 @@ func (h *InventoryHandler) GetCheck(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": 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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user