Files
jiu/backend/internal/handler/stock_out.go
T
wangjia 4fd0bb8b83 feat(backend): 出入库定价字段消歧 + 总利润落库 + 成本仅管理员可见
- 字段重命名(旧列弃用保留,启动幂等回填 BackfillPricingColumns):
  入库 unit_price/total_price/total_amount → cost_price/cost_amount/cost_total;
  出库同前缀 + 新增 sale_amount(售价小计)/ sale_total(应收)/ profit_total(总利润)
- 建单落三值;确认售价联动重算 sale_amount/sale_total/profit_total;
  确认进价回填成本后联动重算受影响出库单利润(recalcStockOutProfit)
- 出库 List/Get 对 operator/readonly 抹零成本与利润(stripStockOutCost 服务端兜底)
- 兼容一版:Create/Update 接受旧 key unit_price 回落(v1.0.87 及之前客户端)
- SUM 聚合/价格趋势/导入/种子工具同步切新列;测试全量改名 + 6 个新回归

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 12:44:22 +08:00

484 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// stripStockOutCost 服务端兜底:成本/利润仅管理员可见(2026-07 用户拍板)。
// operator/readonly 的 List/Get 响应把 cost_price/cost_amount/profit_total 抹零,
// 防止非管理员通过抓包看到成本与利润(前端同时隐藏对应列)。
func stripStockOutCost(role string, orders []model.StockOutOrder) {
if role == "admin" || role == "superadmin" {
return
}
for i := range orders {
orders[i].ProfitTotal = 0
for j := range orders[i].Items {
orders[i].Items[j].CostPrice = 0
orders[i].Items[j].CostAmount = 0
}
}
}
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", "shop_id = ?", shopID).
Preload("Partner", "shop_id = ?", shopID).
Preload("Operator", "shop_id = ?", shopID).
Preload("Reviewer", "shop_id = ?", shopID).
Offset((page - 1) * pageSize).Limit(pageSize).
Order("order_date DESC, id DESC").Find(&orders)
stripStockOutCost(middleware.GetRole(c), orders)
c.JSON(http.StatusOK, gin.H{"data": orders, "total": total, "page": page, "page_size": pageSize})
}
// Summary GET /api/v1/stock-out/summary —— 全店出库 KPI(守多租户;?window=rolling30 见 summaryBounds
func (h *StockOutHandler) Summary(c *gin.Context) {
shopID := middleware.GetShopID(c)
curFrom, curTo, prevFrom, prevTo := summaryBounds(c, 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(sale_total),0) AS amt").Scan(&r)
return r.Cnt, r.Amt
}
var s stockSummary
s.MonthCount, s.MonthAmount = agg(curFrom, curTo)
s.LastMonthCount, s.LastMonthAmount = agg(prevFrom, prevTo)
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", "shop_id = ?", shopID).
Preload("Warehouse", "shop_id = ?", shopID).Preload("Partner", "shop_id = ?", shopID).
Preload("Operator", "shop_id = ?", shopID).Preload("Reviewer", "shop_id = ?", shopID).
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
}
one := []model.StockOutOrder{order}
stripStockOutCost(middleware.GetRole(c), one)
util.RespondSuccess(c, one[0])
}
// 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
}
// SEC-001:外键必须属于当前店(防跨租户引用泄露)
if err := ensureShopRef(h.db, "warehouses", req.WarehouseID, shopID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := ensureShopRefOpt(h.db, "partners", req.PartnerID, shopID); 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, profit float64
for i := range req.Items {
it := &req.Items[i]
it.ShopID = shopID
// 兼容旧客户端(≤1.0.87)unit_price 回落为 cost_price(新 key 优先)
if it.CostPrice == 0 && it.LegacyUnitPrice != nil {
it.CostPrice = *it.LegacyUnitPrice
}
// 成本小计 / 售价小计分列;应收(SaleTotal)与总利润按售价口径
it.CostAmount = it.Quantity * it.CostPrice
it.SaleAmount = it.Quantity * it.SalePrice
total += it.SaleAmount
if it.SalePrice > 0 {
profit += (it.SalePrice - it.CostPrice) * it.Quantity
}
}
req.SaleTotal = total
req.ProfitTotal = profit
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
}
// SEC-001:外键必须属于当前店(防跨租户引用泄露)
if err := ensureShopRef(h.db, "warehouses", req.WarehouseID, shopID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := ensureShopRefOpt(h.db, "partners", req.PartnerID, shopID); 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
var profit float64
for i := range req.Items {
it := &req.Items[i]
it.ShopID = shopID
it.OrderID = order.ID
if it.CostPrice == 0 && it.LegacyUnitPrice != nil {
it.CostPrice = *it.LegacyUnitPrice
}
it.CostAmount = it.Quantity * it.CostPrice
it.SaleAmount = it.Quantity * it.SalePrice
total += it.SaleAmount
if it.SalePrice > 0 {
profit += (it.SalePrice - it.CostPrice) * it.Quantity
}
}
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,
"sale_total": total,
"profit_total": profit,
}
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"})
}