393e227de5
后端: - 新增 product_images 表,支持每商品最多5张图(服务端压缩至1200px/JPEG85%) - products 表新增 public_id(UUID)、description 字段 - 新增商品详情接口、二维码接口、公开商品接口(无鉴权) - 修复 XLS 导入:OLE2 magic bytes 检测 + 临时文件解析,兼容 extrame/xls - 修复商品/名称/系列/规格三张表导入数据为0(LastCol()=0 bug) - 所有导入接口返回 total/imported/skipped 统计 - config 新增 StorageConfig,支持 STORAGE_* 环境变量覆盖 - 种子数据修复:products 补 public_id、新增 product_images TRUNCATE、schema.sql 表名修正 前端: - 商品详情页:图片上传/删除、描述内联编辑、二维码弹窗、公开链接复制 - 公开商品页:无鉴权路由 /product/:public_id,Flutter Web SPA - 商品详情列表(批次追踪)商品名超链接跳转详情页 - 导航「商品管理」改名「商品详情」 - 所有列表表格新增每页条数选择(10/20/50/100) - 表格列头内嵌筛选(FilterableColumnHeader) - 导出 Excel 功能(入库/出库/库存/财务/批次/往来单位) - 网络恢复自动刷新 + 离线缓存展示 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
258 lines
7.9 KiB
Go
258 lines
7.9 KiB
Go
package handler
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"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/service"
|
|
)
|
|
|
|
type StockOutHandler struct {
|
|
db *gorm.DB
|
|
stockSvc *service.StockService
|
|
}
|
|
|
|
func NewStockOutHandler(db *gorm.DB, svc *service.StockService) *StockOutHandler {
|
|
return &StockOutHandler{db: db, stockSvc: svc}
|
|
}
|
|
|
|
// List GET /api/v1/stock-out/orders
|
|
func (h *StockOutHandler) List(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.StockOutOrder{}).
|
|
Where("shop_id = ? AND deleted_at IS NULL", shopID)
|
|
|
|
if status := c.Query("status"); status != "" {
|
|
query = query.Where("status = ?", status)
|
|
}
|
|
if startDate := c.Query("start_date"); startDate != "" {
|
|
query = query.Where("order_date >= ?", startDate)
|
|
}
|
|
if endDate := c.Query("end_date"); endDate != "" {
|
|
query = query.Where("order_date <= ?", endDate)
|
|
}
|
|
|
|
var total int64
|
|
query.Count(&total)
|
|
|
|
var orders []model.StockOutOrder
|
|
query.Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
|
|
Offset((page - 1) * pageSize).Limit(pageSize).
|
|
Order("id DESC").Find(&orders)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"data": orders, "total": total, "page": page, "page_size": pageSize})
|
|
}
|
|
|
|
// Get GET /api/v1/stock-out/orders/:id
|
|
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").
|
|
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"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": order})
|
|
}
|
|
|
|
// checkInventory validates that warehouse has enough stock for each item.
|
|
// warehouseID is the order's warehouse; items are the stock-out line items.
|
|
func (h *StockOutHandler) checkInventory(shopID, warehouseID uint64, items []model.StockOutItem) error {
|
|
for _, item := range items {
|
|
var inv model.Inventory
|
|
err := h.db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
|
|
shopID, warehouseID, item.ProductID).First(&inv).Error
|
|
if err != nil || inv.Quantity < item.Quantity {
|
|
// Try to get product name for a clearer error message
|
|
var p model.Product
|
|
h.db.Where("id = ?", item.ProductID).First(&p)
|
|
name := p.Name
|
|
if name == "" {
|
|
name = fmt.Sprintf("商品ID %d", item.ProductID)
|
|
}
|
|
have := 0.0
|
|
if err == nil {
|
|
have = inv.Quantity
|
|
}
|
|
return fmt.Errorf("库存不足:%s 当前库存 %.0f,需要 %.0f", name, have, item.Quantity)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Create POST /api/v1/stock-out/orders
|
|
func (h *StockOutHandler) Create(c *gin.Context) {
|
|
shopID := middleware.GetShopID(c)
|
|
operatorID := middleware.GetUserID(c)
|
|
|
|
var req model.StockOutOrder
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
req.ShopID = shopID
|
|
req.OperatorID = operatorID
|
|
|
|
// 状态只允许 draft 或 pending;直接提交审核时校验库存
|
|
if req.Status == "pending" {
|
|
if err := h.checkInventory(shopID, req.WarehouseID, req.Items); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
} else {
|
|
req.Status = "draft"
|
|
}
|
|
|
|
orderNo, err := h.stockSvc.GenerateOrderNo(shopID, "stock_out")
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
req.OrderNo = orderNo
|
|
|
|
var total float64
|
|
for i := range req.Items {
|
|
req.Items[i].ShopID = shopID
|
|
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
|
|
total += req.Items[i].TotalPrice
|
|
}
|
|
req.TotalAmount = total
|
|
|
|
if err := h.db.Create(&req).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, gin.H{"data": req})
|
|
}
|
|
|
|
// Update PUT /api/v1/stock-out/orders/:id (只允许草稿状态)
|
|
func (h *StockOutHandler) Update(c *gin.Context) {
|
|
shopID := middleware.GetShopID(c)
|
|
|
|
var order model.StockOutOrder
|
|
if err := h.db.Where("id = ? AND shop_id = ? AND status = 'draft' AND deleted_at IS NULL", c.Param("id"), shopID).
|
|
First(&order).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "出库单不存在或不可修改"})
|
|
return
|
|
}
|
|
|
|
var req model.StockOutOrder
|
|
if err := c.ShouldBindJSON(&req); 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
|
|
}
|
|
var total float64
|
|
for i := range req.Items {
|
|
req.Items[i].ShopID = shopID
|
|
req.Items[i].OrderID = order.ID
|
|
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
|
|
total += req.Items[i].TotalPrice
|
|
}
|
|
updates := map[string]interface{}{
|
|
"warehouse_id": req.WarehouseID,
|
|
"partner_id": req.PartnerID,
|
|
"order_date": req.OrderDate,
|
|
"remark": req.Remark,
|
|
"total_amount": total,
|
|
}
|
|
if err := tx.Model(&order).Updates(updates).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(req.Items) > 0 {
|
|
if err := tx.Create(&req.Items).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "updated"})
|
|
}
|
|
|
|
// Delete DELETE /api/v1/stock-out/orders/:id (只允许草稿状态)
|
|
func (h *StockOutHandler) Delete(c *gin.Context) {
|
|
shopID := middleware.GetShopID(c)
|
|
now := timeNow()
|
|
result := h.db.Model(&model.StockOutOrder{}).
|
|
Where("id = ? AND shop_id = ? AND status = 'draft' AND deleted_at IS NULL", c.Param("id"), shopID).
|
|
Update("deleted_at", now)
|
|
if result.RowsAffected == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "出库单不存在或不可删除"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
|
}
|
|
|
|
// Submit PUT /api/v1/stock-out/orders/:id/submit
|
|
func (h *StockOutHandler) Submit(c *gin.Context) {
|
|
shopID := middleware.GetShopID(c)
|
|
|
|
// 加载订单及明细,校验库存后再改状态
|
|
var order model.StockOutOrder
|
|
if err := h.db.Preload("Items").
|
|
Where("id = ? AND shop_id = ? AND status = 'draft'", c.Param("id"), shopID).
|
|
First(&order).Error; err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in draft status"})
|
|
return
|
|
}
|
|
if err := h.checkInventory(shopID, order.WarehouseID, order.Items); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
h.db.Model(&order).Update("status", "pending")
|
|
c.JSON(http.StatusOK, gin.H{"message": "submitted"})
|
|
}
|
|
|
|
// Approve PUT /api/v1/stock-out/orders/:id/approve
|
|
func (h *StockOutHandler) Approve(c *gin.Context) {
|
|
shopID := middleware.GetShopID(c)
|
|
reviewerID := middleware.GetUserID(c)
|
|
|
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
if err := h.stockSvc.ApproveStockOut(shopID, id, reviewerID); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "approved"})
|
|
}
|
|
|
|
// Reject PUT /api/v1/stock-out/orders/:id/reject
|
|
func (h *StockOutHandler) Reject(c *gin.Context) {
|
|
shopID := middleware.GetShopID(c)
|
|
reviewerID := middleware.GetUserID(c)
|
|
now := timeNow()
|
|
|
|
result := h.db.Model(&model.StockOutOrder{}).
|
|
Where("id = ? AND shop_id = ? AND status = 'pending'", c.Param("id"), shopID).
|
|
Updates(map[string]interface{}{
|
|
"status": "rejected",
|
|
"reviewer_id": reviewerID,
|
|
"reviewed_at": now,
|
|
})
|
|
if result.RowsAffected == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in pending status"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "rejected"})
|
|
}
|