Files
jiu/backend/internal/handler/stock_in.go
T
wangjia bb4f17cf7a feat: 自动更新、系统设置、安全修复
后端:
- 新增 GET /version 版本检查端点(version.go + version.yaml)
- 新增 GET /license/info 接口,返回门店授权信息
- 修复 GenerateOrderNo 并发重复单号:事务内加 FOR UPDATE 行锁
- 修复 ApproveStockOut 超卖竞态:预检和库存更新均加 FOR UPDATE
- 修复 Product Create 并发 code 冲突:加重试逻辑,schema 加 UNIQUE KEY
- 修复 Product Update 全字段覆盖:改用 selective Updates()
- 挂载 ReadOnly 中间件(全局)+ AdminOnly(用户管理路由)
- version.go 配置缺失时返回 500 而非静默降级

前端:
- 新增自动更新检测(update_provider.dart)+ shell 更新 banner/弹窗
- 新增系统设置"关于"标签页:版本、授权、开发信息、意见反馈
- 新增离线缓存:所有 AsyncNotifierProvider 支持断网浏览历史数据
- 新增门店信息弹窗(点击左上角 logo 或右上角门店号触发)
- 提取 AppConfig 统一管理 BASE_URL,支持 --dart-define 注入
- update_provider.dart 加 kIsWeb 保护,修复 Web 平台崩溃
- dev.sh 新增 stop 命令,修复 stop 误杀前端进程问题

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 00:28:08 +08:00

230 lines
6.7 KiB
Go

package handler
import (
"fmt"
"net/http"
"strconv"
"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"
)
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"))
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 = ?", 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)
}
var total int64
query.Count(&total)
var orders []model.StockInOrder
query.Preload("Warehouse").Preload("Partner").Preload("Operator").
Offset((page - 1) * pageSize).Limit(pageSize).
Order("id DESC").Find(&orders)
c.JSON(http.StatusOK, gin.H{"data": orders, "total": total, "page": page, "page_size": pageSize})
}
// 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").Preload("Warehouse").Preload("Partner").
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
}
c.JSON(http.StatusOK, gin.H{"data": 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
}
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
// 计算总金额;自动生成批次号
var total float64
for i := range req.Items {
req.Items[i].ShopID = shopID
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
total += req.Items[i].TotalPrice
if req.Items[i].BatchNo == "" {
req.Items[i].BatchNo = fmt.Sprintf("%s-%02d", req.OrderNo, i+1)
}
}
req.TotalAmount = total
if err := h.db.Create(&req).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": 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
}
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 {
req.Items[i].ShopID = shopID
req.Items[i].OrderID = order.ID
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
total += req.Items[i].TotalPrice
if req.Items[i].BatchNo == "" {
req.Items[i].BatchNo = fmt.Sprintf("%s-%02d", order.OrderNo, i+1)
}
}
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-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"})
}