Files
jiu/backend/internal/handler/stock_out.go
T
wangjia e8f416ddea feat(backend): 入库/出库列表 keyword 支持酒名模糊反查
- keyword 在单号/往来单位基础上,新增按酒名反查含该酒明细的单据
- 酒名匹配复用拼音基建:name(优先 product 回退快照) + name_pinyin + name_initials
- 子查询带 shop_id,多租户隔离不破
- 补 stock_in/stock_out List 按酒名+拼音命中的测试用例

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:19:34 +08:00

319 lines
11 KiB
Go

package handler
import (
"errors"
"net/http"
"strconv"
"strings"
"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"
"github.com/wangjia/jiu/backend/internal/util"
)
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"))
pageSize = util.ValidatePageSize(pageSize, 20, 200)
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 IN ?", strings.Split(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)
}
if kw := strings.TrimSpace(c.Query("keyword")); kw != "" {
like := "%" + kw + "%"
// keyword 同时命中:单号 / 往来单位 / 酒名(含全拼+首字母,反查含该酒明细的单据)。
// 酒名优先取 product 主数据、回退快照列;拼音只在 products 上(快照无拼音列)。
query = query.Where(
"order_no LIKE ?"+
" OR partner_id IN (SELECT id FROM partners WHERE shop_id = ? AND name LIKE ?)"+
" OR id IN (SELECT it.order_id FROM stock_out_items it"+
" LEFT JOIN products p ON p.id = it.product_id AND p.shop_id = it.shop_id"+
" WHERE it.shop_id = ? AND it.deleted_at IS NULL"+
" AND (COALESCE(NULLIF(p.name,''), it.product_name) LIKE ?"+
" OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?))",
like, shopID, like, shopID, like, like, like,
)
}
// 按商品编码反查单据:精确匹配,走 idx_so_items_shop_code(shop_id, product_code) 索引
if code := strings.TrimSpace(c.Query("product_code")); code != "" {
query = query.Where(
"id IN (SELECT order_id FROM stock_out_items WHERE shop_id = ? AND product_code = ? AND deleted_at IS NULL)",
shopID, code,
)
}
var total int64
query.Count(&total)
orders := make([]model.StockOutOrder, 0)
query.Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
Offset((page - 1) * pageSize).Limit(pageSize).
Order("order_date DESC, 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
}
util.RespondSuccess(c, order)
}
// 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.stockSvc.CheckInventoryAvailability(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
// TotalPrice 记成本小计;应收(TotalAmount)按售价×数量
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
total += req.Items[i].Quantity * req.Items[i].SalePrice
}
req.TotalAmount = total
if err := h.db.Create(&req).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
util.RespondCreated(c, 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
// TotalPrice 记成本小计;应收(TotalAmount)按售价×数量
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
total += req.Items[i].Quantity * req.Items[i].SalePrice
}
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.stockSvc.CheckInventoryAvailability(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"})
}
// Withdraw PUT /api/v1/stock-out/orders/:id/withdraw
// 审核中(pending)撤回为草稿(draft),修改后可重新提交。仅 pending 可撤回;
// 已审核(approved)单据只读、库存已变动,不可撤回。
// 权限:管理员/超管可撤回任意单;普通操作员只能撤回本人提交的单(operator_id 为本人)。
func (h *StockOutHandler) Withdraw(c *gin.Context) {
shopID := middleware.GetShopID(c)
userID := middleware.GetUserID(c)
role := middleware.GetRole(c)
var order model.StockOutOrder
if err := h.db.Where("id = ? AND shop_id = ? AND status = 'pending'", c.Param("id"), shopID).
First(&order).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in pending status"})
return
}
isAdmin := role == "admin" || role == "superadmin"
if !isAdmin && order.OperatorID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "只能撤回本人提交的单据"})
return
}
h.db.Model(&model.StockOutOrder{}).
Where("id = ? AND shop_id = ?", order.ID, shopID).
Update("status", "draft")
c.JSON(http.StatusOK, gin.H{"message": "withdrawn"})
}
// Return POST /api/v1/stock-out/orders/:id/return
// 出库退单:退掉指定明细,数量加回库存、冲减应收。仅 approved 单;管理员/超管 或 本人。
func (h *StockOutHandler) Return(c *gin.Context) {
shopID := middleware.GetShopID(c)
userID := middleware.GetUserID(c)
role := middleware.GetRole(c)
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
ItemIDs []uint64 `json:"item_ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.ItemIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "未选择退单明细"})
return
}
if err := h.stockSvc.ReturnStockOut(shopID, id, userID, role, req.ItemIDs); err != nil {
if errors.Is(err, service.ErrForbidden) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权退单"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "returned"})
}