4d1d69595b
- /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
521 lines
19 KiB
Go
521 lines
19 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"
|
||
)
|
||
|
||
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"})
|
||
}
|