init: 后端框架脚手架 (Go + Gin + GORM + MySQL)
- 项目目录结构:backend/ deploy/ schema/ migrations/ - 数据库 Schema:所有建表 SQL,含 hotel_id 多租户隔离 - Go 后端:config、model、handler、service、middleware、router - 认证:账号密码登录 + JWT(Access + Refresh Token) - 许可证:HMAC-SHA256 激活码生成 + 设备绑定验证 - 业务模块:商品、仓库、往来单位、入库、出库、库存、盘点 - 库存事务:入库/出库审核时原子更新库存 + 流水记录 - 数据导入:Excel/CSV 批量导入商品、往来单位 - Docker Compose:本地 MySQL + Adminer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/wangjia/jiu/backend/internal/service"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
svc *service.AuthService
|
||||
}
|
||||
|
||||
func NewAuthHandler(svc *service.AuthService) *AuthHandler {
|
||||
return &AuthHandler{svc: svc}
|
||||
}
|
||||
|
||||
// Login POST /api/v1/auth/login
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req struct {
|
||||
HotelCode string `json:"hotel_code" binding:"required"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
pair, user, err := h.svc.Login(req.HotelCode, req.Username, req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"access_token": pair.AccessToken,
|
||||
"refresh_token": pair.RefreshToken,
|
||||
"expires_in": pair.ExpiresIn,
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"real_name": user.RealName,
|
||||
"role": user.Role,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Refresh POST /api/v1/auth/refresh
|
||||
func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
pair, err := h.svc.RefreshTokens(req.RefreshToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": pair})
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/xuri/excelize/v2"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
type ImportHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewImportHandler(db *gorm.DB) *ImportHandler {
|
||||
return &ImportHandler{db: db}
|
||||
}
|
||||
|
||||
// ImportProducts POST /api/v1/import/products
|
||||
// 支持 .xlsx / .csv,列顺序:名称,系列,规格,单位,品牌,进价,售价,最低库存,备注
|
||||
func (h *ImportHandler) ImportProducts(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
|
||||
return
|
||||
}
|
||||
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
xl, err := excelize.OpenReader(f)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid excel file: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
sheetName := xl.GetSheetName(0)
|
||||
rows, err := xl.GetRows(sheetName)
|
||||
if err != nil || len(rows) < 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "empty or invalid sheet"})
|
||||
return
|
||||
}
|
||||
|
||||
var products []model.Product
|
||||
var errRows []map[string]interface{}
|
||||
|
||||
for i, row := range rows[1:] { // 跳过表头
|
||||
if len(row) < 1 || strings.TrimSpace(row[0]) == "" {
|
||||
continue
|
||||
}
|
||||
p := model.Product{
|
||||
TenantBase: model.TenantBase{HotelID: hotelID},
|
||||
}
|
||||
p.Name = cell(row, 0)
|
||||
p.Series = cell(row, 1)
|
||||
p.Spec = cell(row, 2)
|
||||
p.Unit = cell(row, 3)
|
||||
p.Brand = cell(row, 4)
|
||||
p.Remark = cell(row, 8)
|
||||
|
||||
if p.Name == "" {
|
||||
errRows = append(errRows, map[string]interface{}{"row": i + 2, "error": "name is empty"})
|
||||
continue
|
||||
}
|
||||
products = append(products, p)
|
||||
}
|
||||
|
||||
if len(products) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no valid rows", "errors": errRows})
|
||||
return
|
||||
}
|
||||
|
||||
// 批量写入(upsert by hotel_id+name+spec)
|
||||
if err := h.db.CreateInBatches(&products, 100).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"imported": len(products),
|
||||
"errors": errRows,
|
||||
})
|
||||
}
|
||||
|
||||
// ImportPartners POST /api/v1/import/partners
|
||||
// 列顺序:名称,类型(supplier/customer),联系人,电话,地址,备注
|
||||
func (h *ImportHandler) ImportPartners(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
|
||||
return
|
||||
}
|
||||
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
xl, err := excelize.OpenReader(f)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid excel file"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := xl.GetRows(xl.GetSheetName(0))
|
||||
if err != nil || len(rows) < 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "empty sheet"})
|
||||
return
|
||||
}
|
||||
|
||||
var partners []model.Partner
|
||||
for _, row := range rows[1:] {
|
||||
if len(row) < 1 || strings.TrimSpace(row[0]) == "" {
|
||||
continue
|
||||
}
|
||||
t := cell(row, 1)
|
||||
if t == "" {
|
||||
t = "supplier"
|
||||
}
|
||||
partners = append(partners, model.Partner{
|
||||
TenantBase: model.TenantBase{HotelID: hotelID},
|
||||
Name: cell(row, 0),
|
||||
Type: t,
|
||||
Contact: cell(row, 2),
|
||||
Phone: cell(row, 3),
|
||||
Address: cell(row, 4),
|
||||
Remark: cell(row, 5),
|
||||
})
|
||||
}
|
||||
|
||||
if err := h.db.CreateInBatches(&partners, 100).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"imported": len(partners)})
|
||||
}
|
||||
|
||||
func cell(row []string, idx int) string {
|
||||
if idx >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(row[idx])
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
type InventoryHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewInventoryHandler(db *gorm.DB) *InventoryHandler {
|
||||
return &InventoryHandler{db: db}
|
||||
}
|
||||
|
||||
// List GET /api/v1/inventory
|
||||
func (h *InventoryHandler) List(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
query := h.db.Model(&model.Inventory{}).Where("hotel_id = ?", hotelID)
|
||||
|
||||
if warehouseID := c.Query("warehouse_id"); warehouseID != "" {
|
||||
query = query.Where("warehouse_id = ?", warehouseID)
|
||||
}
|
||||
if productID := c.Query("product_id"); productID != "" {
|
||||
query = query.Where("product_id = ?", productID)
|
||||
}
|
||||
// 仅显示有库存
|
||||
if c.Query("in_stock") == "1" {
|
||||
query = query.Where("quantity > 0")
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var inventory []model.Inventory
|
||||
query.Preload("Product").Preload("Warehouse").
|
||||
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||
Find(&inventory)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": inventory, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
// Logs GET /api/v1/inventory/logs
|
||||
func (h *InventoryHandler) Logs(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
query := h.db.Model(&model.InventoryLog{}).Where("hotel_id = ?", hotelID)
|
||||
|
||||
if productID := c.Query("product_id"); productID != "" {
|
||||
query = query.Where("product_id = ?", productID)
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var logs []model.InventoryLog
|
||||
query.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&logs)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": logs, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
// CreateCheck POST /api/v1/inventory/checks
|
||||
func (h *InventoryHandler) CreateCheck(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
operatorID := middleware.GetUserID(c)
|
||||
|
||||
var req model.InventoryCheck
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
req.HotelID = hotelID
|
||||
req.OperatorID = operatorID
|
||||
req.Status = "draft"
|
||||
|
||||
// 自动填入系统库存数量
|
||||
for i := range req.Items {
|
||||
req.Items[i].HotelID = hotelID
|
||||
var inv model.Inventory
|
||||
if err := h.db.Where("hotel_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
hotelID, req.WarehouseID, req.Items[i].ProductID).First(&inv).Error; err == nil {
|
||||
req.Items[i].SystemQty = inv.Quantity
|
||||
}
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
// GetCheck GET /api/v1/inventory/checks/:id
|
||||
func (h *InventoryHandler) GetCheck(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
var check model.InventoryCheck
|
||||
if err := h.db.Preload("Items.Product").
|
||||
Where("id = ? AND hotel_id = ?", c.Param("id"), hotelID).
|
||||
First(&check).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": check})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/service"
|
||||
)
|
||||
|
||||
type LicenseHandler struct {
|
||||
svc *service.LicenseService
|
||||
}
|
||||
|
||||
func NewLicenseHandler(svc *service.LicenseService) *LicenseHandler {
|
||||
return &LicenseHandler{svc: svc}
|
||||
}
|
||||
|
||||
// Activate POST /api/v1/license/activate
|
||||
func (h *LicenseHandler) Activate(c *gin.Context) {
|
||||
var req struct {
|
||||
LicenseKey string `json:"license_key" binding:"required"`
|
||||
DeviceID string `json:"device_id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
lic, err := h.svc.Activate(req.LicenseKey, req.DeviceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": lic})
|
||||
}
|
||||
|
||||
// Verify GET /api/v1/license/verify
|
||||
func (h *LicenseHandler) Verify(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
deviceID := c.Query("device_id")
|
||||
if deviceID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "device_id required"})
|
||||
return
|
||||
}
|
||||
|
||||
lic, err := h.svc.Verify(hotelID, deviceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": lic})
|
||||
}
|
||||
|
||||
// Deactivate POST /api/v1/license/deactivate
|
||||
func (h *LicenseHandler) Deactivate(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
var req struct {
|
||||
DeviceID string `json:"device_id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Deactivate(hotelID, req.DeviceID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deactivated"})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
type PartnerHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPartnerHandler(db *gorm.DB) *PartnerHandler {
|
||||
return &PartnerHandler{db: db}
|
||||
}
|
||||
|
||||
func (h *PartnerHandler) List(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
query := h.db.Model(&model.Partner{}).
|
||||
Where("hotel_id = ? AND deleted_at IS NULL", hotelID)
|
||||
|
||||
if t := c.Query("type"); t != "" {
|
||||
query = query.Where("FIND_IN_SET(?, type)", t)
|
||||
}
|
||||
if kw := c.Query("keyword"); kw != "" {
|
||||
query = query.Where("name LIKE ? OR phone LIKE ?", "%"+kw+"%", "%"+kw+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var partners []model.Partner
|
||||
query.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&partners)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": partners, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
func (h *PartnerHandler) Create(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
var p model.Partner
|
||||
if err := c.ShouldBindJSON(&p); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
p.HotelID = hotelID
|
||||
if err := h.db.Create(&p).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": p})
|
||||
}
|
||||
|
||||
func (h *PartnerHandler) Update(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
var p model.Partner
|
||||
if err := h.db.Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", c.Param("id"), hotelID).
|
||||
First(&p).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if err := c.ShouldBindJSON(&p); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
p.HotelID = hotelID
|
||||
h.db.Save(&p)
|
||||
c.JSON(http.StatusOK, gin.H{"data": p})
|
||||
}
|
||||
|
||||
func (h *PartnerHandler) Delete(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
now := timeNow()
|
||||
result := h.db.Model(&model.Partner{}).
|
||||
Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", c.Param("id"), hotelID).
|
||||
Update("deleted_at", now)
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
type ProductHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewProductHandler(db *gorm.DB) *ProductHandler {
|
||||
return &ProductHandler{db: db}
|
||||
}
|
||||
|
||||
// List GET /api/v1/products
|
||||
func (h *ProductHandler) List(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
keyword := c.Query("keyword")
|
||||
categoryID := c.Query("category_id")
|
||||
|
||||
query := h.db.Model(&model.Product{}).
|
||||
Where("hotel_id = ? AND deleted_at IS NULL", hotelID)
|
||||
|
||||
if keyword != "" {
|
||||
query = query.Where("name LIKE ? OR code LIKE ? OR barcode LIKE ?",
|
||||
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
if categoryID != "" {
|
||||
query = query.Where("category_id = ?", categoryID)
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var products []model.Product
|
||||
offset := (page - 1) * pageSize
|
||||
query.Preload("Category").Offset(offset).Limit(pageSize).
|
||||
Order("id DESC").Find(&products)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": products,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// Create POST /api/v1/products
|
||||
func (h *ProductHandler) Create(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
var product model.Product
|
||||
if err := c.ShouldBindJSON(&product); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
product.HotelID = hotelID
|
||||
|
||||
if err := h.db.Create(&product).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": product})
|
||||
}
|
||||
|
||||
// Update PUT /api/v1/products/:id
|
||||
func (h *ProductHandler) Update(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
var product model.Product
|
||||
if err := h.db.Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", id, hotelID).
|
||||
First(&product).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&product); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
product.HotelID = hotelID // 防止篡改
|
||||
|
||||
if err := h.db.Save(&product).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": product})
|
||||
}
|
||||
|
||||
// Delete DELETE /api/v1/products/:id (软删除)
|
||||
func (h *ProductHandler) Delete(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
now := timeNow()
|
||||
result := h.db.Model(&model.Product{}).
|
||||
Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", id, hotelID).
|
||||
Update("deleted_at", now)
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"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) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
query := h.db.Model(&model.StockInOrder{}).
|
||||
Where("hotel_id = ? AND deleted_at IS NULL", hotelID)
|
||||
|
||||
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) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
var order model.StockInOrder
|
||||
if err := h.db.Preload("Items.Product").Preload("Warehouse").Preload("Partner").
|
||||
Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", c.Param("id"), hotelID).
|
||||
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) {
|
||||
hotelID := middleware.GetHotelID(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.HotelID = hotelID
|
||||
req.OperatorID = operatorID
|
||||
req.Status = "draft"
|
||||
|
||||
// 生成单号
|
||||
orderNo, err := h.stockSvc.GenerateOrderNo(hotelID, "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].HotelID = hotelID
|
||||
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
|
||||
total += req.Items[i].TotalPrice
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
// Submit PUT /api/v1/stock-in/orders/:id/submit (草稿→待审核)
|
||||
func (h *StockInHandler) Submit(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
result := h.db.Model(&model.StockInOrder{}).
|
||||
Where("id = ? AND hotel_id = ? AND status = 'draft'", c.Param("id"), hotelID).
|
||||
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) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
reviewerID := middleware.GetUserID(c)
|
||||
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.stockSvc.ApproveStockIn(hotelID, 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) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
reviewerID := middleware.GetUserID(c)
|
||||
now := timeNow()
|
||||
|
||||
result := h.db.Model(&model.StockInOrder{}).
|
||||
Where("id = ? AND hotel_id = ? AND status = 'pending'", c.Param("id"), hotelID).
|
||||
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"})
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
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) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
query := h.db.Model(&model.StockOutOrder{}).
|
||||
Where("hotel_id = ? AND deleted_at IS NULL", hotelID)
|
||||
|
||||
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.StockOutOrder
|
||||
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-out/orders/:id
|
||||
func (h *StockOutHandler) Get(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
var order model.StockOutOrder
|
||||
if err := h.db.Preload("Items.Product").Preload("Warehouse").Preload("Partner").
|
||||
Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", c.Param("id"), hotelID).
|
||||
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-out/orders
|
||||
func (h *StockOutHandler) Create(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(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.HotelID = hotelID
|
||||
req.OperatorID = operatorID
|
||||
req.Status = "draft"
|
||||
|
||||
orderNo, err := h.stockSvc.GenerateOrderNo(hotelID, "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].HotelID = hotelID
|
||||
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
|
||||
total += req.Items[i].TotalPrice
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
// Submit PUT /api/v1/stock-out/orders/:id/submit
|
||||
func (h *StockOutHandler) Submit(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
result := h.db.Model(&model.StockOutOrder{}).
|
||||
Where("id = ? AND hotel_id = ? AND status = 'draft'", c.Param("id"), hotelID).
|
||||
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-out/orders/:id/approve
|
||||
func (h *StockOutHandler) Approve(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
reviewerID := middleware.GetUserID(c)
|
||||
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.stockSvc.ApproveStockOut(hotelID, 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) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
reviewerID := middleware.GetUserID(c)
|
||||
now := timeNow()
|
||||
|
||||
result := h.db.Model(&model.StockOutOrder{}).
|
||||
Where("id = ? AND hotel_id = ? AND status = 'pending'", c.Param("id"), hotelID).
|
||||
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"})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
type WarehouseHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewWarehouseHandler(db *gorm.DB) *WarehouseHandler {
|
||||
return &WarehouseHandler{db: db}
|
||||
}
|
||||
|
||||
func (h *WarehouseHandler) List(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
var warehouses []model.Warehouse
|
||||
h.db.Where("hotel_id = ? AND deleted_at IS NULL", hotelID).Find(&warehouses)
|
||||
c.JSON(http.StatusOK, gin.H{"data": warehouses})
|
||||
}
|
||||
|
||||
func (h *WarehouseHandler) Create(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
var w model.Warehouse
|
||||
if err := c.ShouldBindJSON(&w); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
w.HotelID = hotelID
|
||||
h.db.Create(&w)
|
||||
c.JSON(http.StatusCreated, gin.H{"data": w})
|
||||
}
|
||||
|
||||
func (h *WarehouseHandler) Update(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
var w model.Warehouse
|
||||
if err := h.db.Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", c.Param("id"), hotelID).
|
||||
First(&w).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.ShouldBindJSON(&w)
|
||||
w.HotelID = hotelID
|
||||
h.db.Save(&w)
|
||||
c.JSON(http.StatusOK, gin.H{"data": w})
|
||||
}
|
||||
|
||||
func (h *WarehouseHandler) Delete(c *gin.Context) {
|
||||
hotelID := middleware.GetHotelID(c)
|
||||
now := timeNow()
|
||||
h.db.Model(&model.Warehouse{}).
|
||||
Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", c.Param("id"), hotelID).
|
||||
Update("deleted_at", now)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
Reference in New Issue
Block a user