9099c4af99
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
423 lines
14 KiB
Go
423 lines
14 KiB
Go
package handler
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"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/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 主数据名 OR 明细快照名」任一命中——不能用 COALESCE 二选一:历史导入单的
|
|
// 明细 product_id 统一指向占位商品「历史导入占位」(p.name 非空),COALESCE 会被占位名劫持,
|
|
// 永远取不到真实快照 product_name,导致导入单按真实酒名搜不到。拼音只在 products 上(快照无拼音列)。
|
|
// 编码同理「主数据 p.code OR 快照 it.product_code」双匹配:存量应用建单的明细快照为空串,
|
|
// 只查快照会「页面看得到编码、搜索搜不到」。
|
|
query = query.Where(
|
|
"order_no 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 (p.name LIKE ? OR it.product_name LIKE ? OR it.product_code LIKE ?"+
|
|
" OR p.code LIKE ? OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?))",
|
|
like, shopID, like, like, like, like, like, like,
|
|
)
|
|
}
|
|
// 详细搜索多字段(各参数独立可选、组合为 AND)
|
|
query = applyStockOrderDetailFilters(query, shopID, c, "stock_out_items")
|
|
|
|
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})
|
|
}
|
|
|
|
// Summary GET /api/v1/stock-out/summary —— 全店出库 KPI(守多租户)
|
|
func (h *StockOutHandler) Summary(c *gin.Context) {
|
|
shopID := middleware.GetShopID(c)
|
|
mStart, next, prev := monthBounds(time.Now())
|
|
agg := func(from, to string) (int64, float64) {
|
|
var r struct {
|
|
Cnt int64
|
|
Amt float64
|
|
}
|
|
h.db.Model(&model.StockOutOrder{}).
|
|
Where("shop_id = ? AND deleted_at IS NULL AND order_date >= ? AND order_date < ?", shopID, from, to).
|
|
Select("COUNT(*) AS cnt, COALESCE(SUM(total_amount),0) AS amt").Scan(&r)
|
|
return r.Cnt, r.Amt
|
|
}
|
|
var s stockSummary
|
|
s.MonthCount, s.MonthAmount = agg(mStart, next)
|
|
s.LastMonthCount, s.LastMonthAmount = agg(prev, mStart)
|
|
h.db.Model(&model.StockOutOrder{}).
|
|
Where("shop_id = ? AND status = ? AND deleted_at IS NULL", shopID, "pending").Count(&s.PendingCount)
|
|
c.JSON(http.StatusOK, s)
|
|
}
|
|
|
|
// ConfirmSale PUT/POST /api/v1/stock-out/orders/:id/confirm-sale —— 确认售价(先出后定价)
|
|
func (h *StockOutHandler) ConfirmSale(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 {
|
|
Items []service.SaleConfirmItem `json:"items"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if len(req.Items) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "未提供要确认的明细"})
|
|
return
|
|
}
|
|
for _, it := range req.Items {
|
|
if it.SalePrice <= 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "售价必须大于 0"})
|
|
return
|
|
}
|
|
}
|
|
if err := h.stockSvc.ConfirmStockOutSale(shopID, id, userID, role, req.Items); 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": "sale confirmed"})
|
|
}
|
|
|
|
// 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
|
|
// fillStockOutItemSnapshots 按 product_id 从商品主数据拷明细快照列(编码/名称/系列/规格/批次/生产日期)。
|
|
// 明细 = product 引用 + 快照(历史保真:商品日后改名/删除,单据仍能还原当时信息;搜索/退单提示读快照)。
|
|
// 入库建单在 createIndependentProduct 后即填快照,出库同样必须在建单/改单时填齐。
|
|
func fillStockOutItemSnapshots(db *gorm.DB, shopID uint64, items []model.StockOutItem) error {
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
ids := make([]uint64, 0, len(items))
|
|
for i := range items {
|
|
ids = append(ids, items[i].ProductID)
|
|
}
|
|
var prods []model.Product
|
|
if err := db.Where("shop_id = ? AND id IN ?", shopID, ids).Find(&prods).Error; err != nil {
|
|
return err
|
|
}
|
|
byID := make(map[uint64]*model.Product, len(prods))
|
|
for i := range prods {
|
|
byID[prods[i].ID] = &prods[i]
|
|
}
|
|
for i := range items {
|
|
p, ok := byID[items[i].ProductID]
|
|
if !ok {
|
|
return fmt.Errorf("明细第 %d 行商品不存在", i+1)
|
|
}
|
|
items[i].ProductCode = p.Code
|
|
items[i].ProductName = p.Name
|
|
items[i].Series = p.Series
|
|
items[i].Spec = p.Spec
|
|
if items[i].BatchNo == "" {
|
|
items[i].BatchNo = p.BatchNo
|
|
}
|
|
if items[i].ProductionDate == nil {
|
|
items[i].ProductionDate = p.ProductionDate
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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 := fillStockOutItemSnapshots(h.db, shopID, req.Items); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
if err := fillStockOutItemSnapshots(tx, shopID, req.Items); err != nil {
|
|
return err
|
|
}
|
|
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"})
|
|
}
|