Files
jiu/backend/internal/handler/stock_in.go
T
wangjia 4d1d69595b fix(backend): 财务汇总 500(only_full_group_by 预处理协议回归)+ summary 加 draft_count
- /finance/summary:SEC-001 给 JOIN 加 shop_id 条件后,MySQL 预处理协议推导不出
  p.name 函数依赖 → 1055(文本协议可过,CLI 无法复现)。GROUP BY 补 p.name 修复,
  并给该 handler 加错误日志(此前吞错致排查困难)。v1.0.85 起全店受影响。
- 出入库 summary 新增 draft_count(草稿单数 KPI 卡数据源)。

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

521 lines
19 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"
)
func timeNow() *time.Time {
t := time.Now()
return &t
}
type StockInHandler struct {
db *gorm.DB
stockSvc *service.StockService
}
func NewStockInHandler(db *gorm.DB, svc *service.StockService) *StockInHandler {
return &StockInHandler{db: db, stockSvc: svc}
}
// List GET /api/v1/stock-in/orders
func (h *StockInHandler) 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.StockInOrder{}).
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_in_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_in_items")
var total int64
query.Count(&total)
orders := make([]model.StockInOrder, 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)
c.JSON(http.StatusOK, gin.H{"data": orders, "total": total, "page": page, "page_size": pageSize})
}
// applyStockOrderDetailFilters 详细搜索多字段过滤(出入库共用,itemsTable 传 stock_in_items / stock_out_items
// 为硬编码常量非用户输入,无注入风险)。各参数独立可选:单据表字段直接过滤,明细字段走子查询 IN。
func applyStockOrderDetailFilters(q *gorm.DB, shopID uint64, c *gin.Context, itemsTable string) *gorm.DB {
if v := strings.TrimSpace(c.Query("order_no")); v != "" {
q = q.Where("order_no LIKE ?", "%"+v+"%")
}
if v := c.Query("partner_id"); v != "" {
q = q.Where("partner_id = ?", v)
}
if v := c.Query("operator_id"); v != "" {
q = q.Where("operator_id = ?", v)
}
if v := c.Query("reviewer_id"); v != "" {
q = q.Where("reviewer_id = ?", v)
}
// 退单状态(none/partial/full;单据表直接过滤,供「部分退单/已退单」筛选)
if v := c.Query("return_state"); v != "" {
q = q.Where("return_state = ?", v)
}
// 明细/商品级条件合并为「一条」子查询:命中要求**同一条明细**同时满足全部条件
// product/编码/系列/规格/批次/分类),而非「各条件各自由不同明细凑齐」。
// 一次扫描 items(走 idx_order_id 回连单据),语义更准、比多条 id IN 更省。
var itemConds []string
var itemArgs []interface{}
if v := strings.TrimSpace(c.Query("product")); v != "" {
like := "%" + v + "%"
itemConds = append(itemConds,
"(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 ?)")
itemArgs = append(itemArgs, like, like, like, like, like, like)
}
// 编码「主数据 OR 快照」双匹配:存量出库明细快照有空串,只查快照会漏单
if v := strings.TrimSpace(c.Query("product_code")); v != "" {
like := "%" + v + "%"
itemConds = append(itemConds, "(it.product_code LIKE ? OR p.code LIKE ?)")
itemArgs = append(itemArgs, like, like)
}
if v := strings.TrimSpace(c.Query("series")); v != "" {
itemConds = append(itemConds, "it.series = ?")
itemArgs = append(itemArgs, v)
}
if v := strings.TrimSpace(c.Query("spec")); v != "" {
itemConds = append(itemConds, "it.spec = ?")
itemArgs = append(itemArgs, v)
}
if v := strings.TrimSpace(c.Query("batch")); v != "" {
itemConds = append(itemConds, "it.batch_no LIKE ?")
itemArgs = append(itemArgs, "%"+v+"%")
}
if v := c.Query("category_id"); v != "" {
itemConds = append(itemConds, "p.category_id = ?")
itemArgs = append(itemArgs, v)
}
if len(itemConds) > 0 {
sub := "id IN (SELECT it.order_id FROM " + itemsTable + " 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 " +
strings.Join(itemConds, " AND ") + ")"
args := append([]interface{}{shopID}, itemArgs...)
q = q.Where(sub, args...)
}
return q
}
// stockSummary 出/入库 KPI 汇总(本月 + 上月同期供环比 + 待审核)。
type stockSummary struct {
MonthCount int64 `json:"month_count"`
MonthAmount float64 `json:"month_amount"`
PendingCount int64 `json:"pending_count"`
DraftCount int64 `json:"draft_count"`
LastMonthCount int64 `json:"last_month_count"`
LastMonthAmount float64 `json:"last_month_amount"`
}
// monthBounds 本月起 / 下月起 / 上月起(YYYY-MM-DD 串,日期串比较跨 MySQL/SQLite 可移植)。
func monthBounds(now time.Time) (mStart, next, prev string) {
s := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
const f = "2006-01-02"
return s.Format(f), s.AddDate(0, 1, 0).Format(f), s.AddDate(0, -1, 0).Format(f)
}
// summaryBounds 依 ?window= 选统计口径:
// - rolling30:近 30 天滚动窗 [今-29d, 明),对照窗为再往前 30 天——出入库列表
// KPI 用(月初自然月全 0 的观感问题,用户拍板改滚动窗);
// - 默认:自然月 + 上月(原型/财务屏口径)。
//
// 返回 YYYY-MM-DD 串,日期串比较跨 MySQL/SQLite 可移植。
func summaryBounds(c *gin.Context, now time.Time) (curFrom, curTo, prevFrom, prevTo string) {
const f = "2006-01-02"
if c.Query("window") == "rolling30" {
d := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
return d.AddDate(0, 0, -29).Format(f), d.AddDate(0, 0, 1).Format(f),
d.AddDate(0, 0, -59).Format(f), d.AddDate(0, 0, -29).Format(f)
}
mStart, next, prev := monthBounds(now)
return mStart, next, prev, mStart
}
// Summary GET /api/v1/stock-in/summary —— 全店入库 KPI(守多租户;?window=rolling30 见 summaryBounds
func (h *StockInHandler) 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.StockInOrder{}).
Where("shop_id = ? AND deleted_at IS NULL AND order_date >= ? AND order_date < ?", shopID, from, to).
Select("COUNT(*) AS cnt, COALESCE(SUM(cost_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.StockInOrder{}).
Where("shop_id = ? AND status = ? AND deleted_at IS NULL", shopID, "pending").Count(&s.PendingCount)
h.db.Model(&model.StockInOrder{}).
Where("shop_id = ? AND status = ? AND deleted_at IS NULL", shopID, "draft").Count(&s.DraftCount)
c.JSON(http.StatusOK, s)
}
// Get GET /api/v1/stock-in/orders/:id
func (h *StockInHandler) Get(c *gin.Context) {
shopID := middleware.GetShopID(c)
var order model.StockInOrder
if err := h.db.Preload("Items.Product", "shop_id = ?", shopID).Preload("Items.Product.Origin").
Preload("Items.Product.ShelfLife").Preload("Items.Product.Storage").
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
}
util.RespondSuccess(c, order)
}
// Create POST /api/v1/stock-in/orders
func (h *StockInHandler) Create(c *gin.Context) {
shopID := middleware.GetShopID(c)
operatorID := middleware.GetUserID(c)
var req model.StockInOrder
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
req.Status = "draft"
// 生成单号
orderNo, err := h.stockSvc.GenerateOrderNo(shopID, "stock_in")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
req.OrderNo = orderNo
// 事务内:每条明细新建一个独立产品(特有产品/序列号,不按名称复用),回填 product_id,再建单。
err = h.db.Transaction(func(tx *gorm.DB) error {
var total 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
}
if it.BatchNo == "" {
it.BatchNo = fmt.Sprintf("%s-%02d", req.OrderNo, i+1)
}
prod, e := createIndependentProduct(tx, shopID, it.ProductName, it.Series, it.Spec, it.BatchNo, it.ProductionDate, it.CostPrice, it.SalePrice)
if e != nil {
return e
}
it.ProductID = prod.ID
it.ProductCode = prod.Code // 保留快照,兼容现有查询(瘦身阶段再去)
it.CostAmount = it.Quantity * it.CostPrice
total += it.CostAmount
}
req.CostTotal = total
return tx.Create(&req).Error
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
util.RespondCreated(c, req)
}
// Update PUT /api/v1/stock-in/orders/:id (只允许草稿状态)
func (h *StockInHandler) Update(c *gin.Context) {
shopID := middleware.GetShopID(c)
var order model.StockInOrder
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.StockInOrder
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.StockInItem{}).Error; err != nil {
return err
}
var total 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
}
if it.BatchNo == "" {
it.BatchNo = fmt.Sprintf("%s-%02d", order.OrderNo, i+1)
}
// 编辑草稿:旧明细已删,每条按新模型重建独立产品(旧草稿 product 无库存,暂留待后续清理)
prod, e := createIndependentProduct(tx, shopID, it.ProductName, it.Series, it.Spec, it.BatchNo, it.ProductionDate, it.CostPrice, it.SalePrice)
if e != nil {
return e
}
it.ProductID = prod.ID
it.ProductCode = prod.Code
it.CostAmount = it.Quantity * it.CostPrice
total += it.CostAmount
}
updates := map[string]interface{}{
"warehouse_id": req.WarehouseID,
"partner_id": req.PartnerID,
"order_date": req.OrderDate,
"remark": req.Remark,
"cost_total": 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-in/orders/:id (只允许草稿状态)
func (h *StockInHandler) Delete(c *gin.Context) {
shopID := middleware.GetShopID(c)
now := timeNow()
result := h.db.Model(&model.StockInOrder{}).
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-in/orders/:id/submit (草稿→待审核)
func (h *StockInHandler) Submit(c *gin.Context) {
shopID := middleware.GetShopID(c)
result := h.db.Model(&model.StockInOrder{}).
Where("id = ? AND shop_id = ? AND status = 'draft'", c.Param("id"), shopID).
Update("status", "pending")
if result.RowsAffected == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in draft status"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "submitted"})
}
// Approve PUT /api/v1/stock-in/orders/:id/approve
func (h *StockInHandler) Approve(c *gin.Context) {
shopID := middleware.GetShopID(c)
reviewerID := middleware.GetUserID(c)
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.stockSvc.ApproveStockIn(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-in/orders/:id/reject
func (h *StockInHandler) Reject(c *gin.Context) {
shopID := middleware.GetShopID(c)
reviewerID := middleware.GetUserID(c)
now := timeNow()
result := h.db.Model(&model.StockInOrder{}).
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-in/orders/:id/withdraw
// 审核中(pending)撤回为草稿(draft),修改后可重新提交。仅 pending 可撤回;
// 已审核(approved)单据只读、库存已变动,不可撤回。
// 权限:管理员/超管可撤回任意单;普通操作员只能撤回本人提交的单(operator_id 为本人)。
func (h *StockInHandler) Withdraw(c *gin.Context) {
shopID := middleware.GetShopID(c)
userID := middleware.GetUserID(c)
role := middleware.GetRole(c)
var order model.StockInOrder
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.StockInOrder{}).
Where("id = ? AND shop_id = ?", order.ID, shopID).
Update("status", "draft")
c.JSON(http.StatusOK, gin.H{"message": "withdrawn"})
}
// Return POST /api/v1/stock-in/orders/:id/return
// 入库退单:退掉指定明细,对应库存从库存删除、冲减应付。仅 approved 单、管理员/超管。
func (h *StockInHandler) 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.ReturnStockIn(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"})
}
// ConfirmCost POST /api/v1/stock-in/orders/:id/confirm-cost
// 确认进价:调货等以 0 价(暂估)入库并已审核后,价格确定时补填真实进价。
// 前向补偿(更新库存成本、回填已出库成本快照、补应付差额),不反审核。
// 仅 approved 单、管理员/超管(service 内判权)。
func (h *StockInHandler) ConfirmCost(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.CostConfirmItem `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.UnitPrice <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "进价必须大于 0"})
return
}
}
if err := h.stockSvc.ConfirmStockInCost(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": "cost confirmed"})
}