package handler import ( "database/sql" "errors" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/shopspring/decimal" "github.com/wmms/wmms/internal/middleware" "github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/repository" "github.com/wmms/wmms/internal/service" "github.com/wmms/wmms/pkg/response" ) type Handler struct { materialSvc *service.MaterialService inboundSvc *service.InboundService outboundSvc *service.OutboundService stockSvc *service.StockService logSvc *service.MaterialLogService authSvc *service.AuthService appSvc *service.ApplicationService organizationSvc *service.OrganizationService masterDataSvc *service.MasterDataService partnerSvc *service.PartnerService warehouseSvc *service.WarehouseService } func userFacingServiceErrorMessage(err error) string { if errors.Is(err, service.ErrInsufficientStock) { return "库存不足" } return err.Error() } func NewHandler() *Handler { return &Handler{ materialSvc: service.NewMaterialService(), inboundSvc: service.NewInboundService(), outboundSvc: service.NewOutboundService(), stockSvc: service.NewStockService(), logSvc: service.NewMaterialLogService(), authSvc: service.NewAuthService(), appSvc: service.NewApplicationService(), organizationSvc: service.NewOrganizationService(), masterDataSvc: service.NewMasterDataService(), partnerSvc: service.NewPartnerService(), warehouseSvc: service.NewWarehouseService(), } } func (h *Handler) RegisterRoutes(r *gin.Engine) { // Health check (public) r.GET("/api/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) api := r.Group("/api") { // Protected upload route upload := api.Group("/upload") upload.Use(middleware.AuthMiddleware()) { upload.POST("", h.UploadImage) } // Auth routes (public, with rate limiting) auth := api.Group("/auth") auth.Use(middleware.RateLimitMiddleware()) { auth.POST("/login", h.Login) } // Protected routes logs := api.Group("/logs") logs.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware()) { logs.GET("", h.ListLogs) logs.GET("/materials/:materialId", h.GetLogsByMaterial) } materials := api.Group("/materials") materials.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware()) { materials.GET("", h.ListMaterials) materials.GET("/:id", h.GetMaterial) materials.POST("", h.CreateMaterial) materials.PUT("/:id", h.UpdateMaterial) materials.POST("/:id/obsolete", h.ObsoleteMaterial) materials.POST("/:id/restore", h.RestoreMaterial) } inbound := api.Group("/inbound") inbound.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware()) { inbound.GET("", h.ListInbound) inbound.GET("/:id", h.GetInbound) inbound.POST("", h.CreateInbound) inbound.PUT("/:id", h.UpdateInbound) inbound.DELETE("/:id", h.DeleteInbound) inbound.GET("/materials/:materialId", h.GetInboundByMaterial) } outbound := api.Group("/outbound") outbound.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware()) { outbound.GET("", h.ListOutbound) outbound.GET("/:id", h.GetOutbound) outbound.POST("", h.CreateOutbound) outbound.PUT("/:id", h.UpdateOutbound) outbound.DELETE("/:id", h.DeleteOutbound) outbound.GET("/materials/:materialId", h.GetOutboundByMaterial) } stock := api.Group("/stock") stock.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware()) { stock.GET("", h.ListStock) stock.GET("/warning", h.GetWarningStock) stock.GET("/logs", h.GetAllStockLogs) stock.GET("/logs/:materialId", h.GetStockLogs) stock.PUT("/:materialId/warning", h.UpdateWarningStock) stock.GET("/:materialId/details", h.GetInventoryBalanceDetails) stock.GET("/:materialId", h.GetStock) } // Admin only routes users := api.Group("/users") users.Use(middleware.AuthMiddleware(), middleware.AdminOnly()) { users.GET("", h.ListUsers) users.POST("", h.CreateUser) users.PUT("/:id", h.UpdateUser) users.DELETE("/:id", h.DeleteUser) } organizations := api.Group("/organizations") organizations.Use(middleware.AuthMiddleware(), middleware.AdminOnly()) { organizations.GET("", h.ListOrganizations) organizations.POST("", h.CreateOrganization) organizations.PUT("/:id", h.UpdateOrganization) } roles := api.Group("/roles") roles.Use(middleware.AuthMiddleware(), middleware.AdminOnly()) { roles.GET("", h.ListRoles) roles.PUT("/:id", h.UpdateRole) } permissions := api.Group("/permissions") permissions.Use(middleware.AuthMiddleware(), middleware.AdminOnly()) { permissions.GET("", h.ListPermissions) } categories := api.Group("/material-categories") categories.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware()) { categories.GET("", h.ListMaterialCategories) categories.POST("", middleware.AdminOnly(), h.CreateMaterialCategory) categories.PUT("/:id", middleware.AdminOnly(), h.UpdateMaterialCategory) } units := api.Group("/units") units.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware()) { units.GET("", h.ListUnits) units.POST("", middleware.AdminOnly(), h.CreateUnit) units.PUT("/:id", middleware.AdminOnly(), h.UpdateUnit) } codeRules := api.Group("/code-rules") codeRules.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware()) { codeRules.GET("", h.ListCodeRules) codeRules.POST("", middleware.AdminOnly(), h.CreateCodeRule) codeRules.PUT("/:id", middleware.AdminOnly(), h.UpdateCodeRule) } // 编码预览单独放开给普通用户:新增物料时要靠它得到自动编码, // 而编码规则本身的增删改仍然只有管理员能做。 codeRulePreview := api.Group("/code-rules") codeRulePreview.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware()) { codeRulePreview.GET("/next", h.GetNextMaterialCode) } partners := api.Group("/partners") partners.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware(), middleware.AdminOnly()) { partners.GET("", h.ListPartners) partners.POST("", h.CreatePartner) partners.PUT("/:id", h.UpdatePartner) } warehouses := api.Group("/warehouses") warehouses.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware(), middleware.AdminOnly()) { warehouses.GET("", h.ListWarehouses) warehouses.POST("", h.CreateWarehouse) warehouses.PUT("/:id", h.UpdateWarehouse) warehouses.POST("/:id/locations", h.CreateWarehouseLocation) warehouses.PUT("/:id/locations/:locationId", h.UpdateWarehouseLocation) } // Application routes (user: create/cancel own, list own; admin: list all, approve/reject) applications := api.Group("/applications") applications.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware()) { applications.POST("", h.CreateApplication) applications.GET("/my", h.ListMyApplications) applications.POST("/:id/cancel", h.CancelApplication) applications.GET("", h.ListApplications) // admin only applications.POST("/:id/approve", h.ApproveApplication) // admin only applications.POST("/:id/reject", h.RejectApplication) // admin only } // Current user route me := api.Group("/auth") me.Use(middleware.AuthMiddleware()) { me.GET("/organizations", h.ListAccessibleOrganizations) me.POST("/organization-context", h.SwitchOrganizationContext) me.GET("/me", h.GetCurrentUser) me.POST("/logout", h.Logout) // WebSocket 建连前的票据签发:需要登录与已校验的公司头,返回短期一次性票据。 me.POST("/websocket-ticket", middleware.OrganizationMiddleware(), h.CreateWebSocketTicket) } } } // Material handlers func (h *Handler) ListMaterials(c *gin.Context) { search := c.Query("search") status := c.DefaultQuery("status", "active") if status != "active" && status != "obsolete" && status != "all" { response.BadRequest(c, "status must be active, obsolete, or all") return } limit := 20 offset := 0 if l := c.Query("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed if limit > 100 { limit = 100 } } } if o := c.Query("offset"); o != "" { if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { offset = parsed } } orgID, ok := organizationID(c) if !ok { return } materials, total, err := h.materialSvc.List(orgID, search, status, limit, offset) if err != nil { response.InternalError(c, err.Error()) return } if materials == nil { materials = []model.Material{} } response.PaginatedSuccess(c, materials, limit, offset, total) } func (h *Handler) GetMaterial(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "invalid material id") return } orgID, ok := organizationID(c) if !ok { return } material, err := h.materialSvc.GetByID(orgID, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { response.NotFound(c, "Material not found") return } response.InternalError(c, err.Error()) return } response.Success(c, material) } func (h *Handler) CreateMaterial(c *gin.Context) { var req model.CreateMaterialRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } orgID, ok := organizationID(c) if !ok { return } req.OrganizationID = orgID operator := middleware.GetCurrentUsername(c) if operator == "" { operator = "系统" } material, err := h.materialSvc.Create(&req, operator) if err != nil { if errors.Is(err, service.ErrInvalidMaterialCategory) || errors.Is(err, service.ErrInvalidMaterialUnit) { response.BadRequest(c, err.Error()) return } if errors.Is(err, repository.ErrDuplicateMaterialCode) { response.BadRequest(c, err.Error()) return } response.InternalError(c, err.Error()) return } response.Created(c, "Material created successfully", material) } func (h *Handler) UpdateMaterial(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "invalid material id") return } var req model.UpdateMaterialRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } orgID, ok := organizationID(c) if !ok { return } operator := middleware.GetCurrentUsername(c) if operator == "" { operator = "系统" } material, err := h.materialSvc.Update(orgID, id, &req, operator) if err != nil { if errors.Is(err, service.ErrInvalidMaterialCategory) || errors.Is(err, service.ErrInvalidMaterialUnit) { response.BadRequest(c, err.Error()) return } if errors.Is(err, sql.ErrNoRows) { response.NotFound(c, "Material not found") return } response.InternalError(c, err.Error()) return } response.SuccessWithMessage(c, "Material updated successfully", material) } func (h *Handler) DeleteMaterial(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "invalid material id") return } orgID, ok := organizationID(c) if !ok { return } operator := middleware.GetCurrentUsername(c) if operator == "" { operator = "系统" } err = h.materialSvc.Delete(orgID, id, operator) if err != nil { if errors.Is(err, sql.ErrNoRows) { response.NotFound(c, "Material not found") return } response.InternalError(c, err.Error()) return } response.SuccessWithMessage(c, "Material deleted successfully", nil) } func (h *Handler) ObsoleteMaterial(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "invalid material id") return } orgID, ok := organizationID(c) if !ok { return } operator := middleware.GetCurrentUsername(c) if operator == "" { operator = "系统" } if err := h.materialSvc.Obsolete(orgID, id, operator); err != nil { if strings.Contains(err.Error(), "仍有库存") { response.Error(c, 409, err.Error()) return } // 不存在或不属于本公司:读取阶段返回 sql.ErrNoRows,事务阶段返回"物料不存在", // 两者都按 404 处理,不能让跨公司操作看起来像服务器错误。 if errors.Is(err, sql.ErrNoRows) || strings.Contains(err.Error(), "物料不存在") { response.Error(c, 404, "物料不存在") return } response.InternalError(c, err.Error()) return } response.SuccessWithMessage(c, "Material obsoleted successfully", nil) } func (h *Handler) RestoreMaterial(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "invalid material id") return } orgID, ok := organizationID(c) if !ok { return } operator := middleware.GetCurrentUsername(c) if operator == "" { operator = "系统" } if err := h.materialSvc.Restore(orgID, id, operator); err != nil { if errors.Is(err, sql.ErrNoRows) || strings.Contains(err.Error(), "物料不存在") { response.Error(c, 404, "物料不存在") return } response.InternalError(c, err.Error()) return } response.SuccessWithMessage(c, "Material restored successfully", nil) } // Inbound handlers func (h *Handler) ListInbound(c *gin.Context) { search := c.Query("search") limit := 20 offset := 0 if l := c.Query("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed if limit > 100 { limit = 100 } } } if o := c.Query("offset"); o != "" { if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { offset = parsed } } orgID, ok := organizationID(c) if !ok { return } orders, total, err := h.inboundSvc.List(orgID, search, limit, offset) if err != nil { response.InternalError(c, err.Error()) return } if orders == nil { orders = []model.InboundOrder{} } response.PaginatedSuccess(c, orders, limit, offset, total) } func (h *Handler) GetInbound(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "invalid inbound order id") return } orgID, ok := organizationID(c) if !ok { return } order, err := h.inboundSvc.GetByID(orgID, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { response.NotFound(c, "Inbound order not found") return } response.InternalError(c, err.Error()) return } response.Success(c, order) } func (h *Handler) CreateInbound(c *gin.Context) { var req model.CreateInboundRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } orgID, ok := organizationID(c) if !ok { return } operator := middleware.GetCurrentUsername(c) if operator == "" { operator = "系统" } order, err := h.inboundSvc.Create(orgID, &req, operator) if err != nil { if errors.Is(err, service.ErrMaterialNotFound) { response.BadRequest(c, "material not found") return } if errors.Is(err, service.ErrMaterialObsolete) { response.BadRequest(c, "material is obsolete") return } if errors.Is(err, service.ErrInventoryScopeUnavailable) { response.Error(c, 409, err.Error()) return } response.InternalError(c, err.Error()) return } // 事务已提交后再广播,且带公司归属,仅同公司订阅者会收到。 GetHub().BroadcastStockUpdate(orgID, "inbound", order.ID, order) response.Created(c, "Inbound order created successfully", order) } func (h *Handler) DeleteInbound(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "invalid inbound order id") return } orgID, ok := organizationID(c) if !ok { return } err = h.inboundSvc.Delete(orgID, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { response.NotFound(c, "Inbound order not found") return } if errors.Is(err, service.ErrInventoryScopeUnavailable) { response.Error(c, 409, err.Error()) return } response.InternalError(c, err.Error()) return } response.SuccessWithMessage(c, "Inbound order deleted successfully", nil) } func (h *Handler) UpdateInbound(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "invalid inbound order id") return } var req model.UpdateInboundRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } orgID, ok := organizationID(c) if !ok { return } order, err := h.inboundSvc.Update(orgID, id, &req) if err != nil { if errors.Is(err, sql.ErrNoRows) { response.NotFound(c, "Inbound order not found") return } if errors.Is(err, service.ErrMaterialNotFound) { response.BadRequest(c, "material not found") return } if errors.Is(err, service.ErrMaterialObsolete) { response.BadRequest(c, "material is obsolete") return } if errors.Is(err, service.ErrInventoryScopeUnavailable) { response.Error(c, 409, err.Error()) return } response.InternalError(c, err.Error()) return } GetHub().BroadcastStockUpdate(orgID, "inbound", order.ID, order) response.Success(c, order) } func (h *Handler) GetInboundByMaterial(c *gin.Context) { materialID, err := strconv.ParseInt(c.Param("materialId"), 10, 64) if err != nil { response.BadRequest(c, "invalid material id") return } orgID, ok := organizationID(c) if !ok { return } records, err := h.inboundSvc.GetByMaterialID(orgID, materialID) if err != nil { response.InternalError(c, err.Error()) return } if records == nil { records = []model.InboundRecord{} } response.Success(c, records) } // Outbound handlers func (h *Handler) ListOutbound(c *gin.Context) { search := c.Query("search") limit := 20 offset := 0 if l := c.Query("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed if limit > 100 { limit = 100 } } } if o := c.Query("offset"); o != "" { if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { offset = parsed } } orgID, ok := organizationID(c) if !ok { return } orders, total, err := h.outboundSvc.List(orgID, search, limit, offset) if err != nil { response.InternalError(c, err.Error()) return } if orders == nil { orders = []model.OutboundOrder{} } response.PaginatedSuccess(c, orders, limit, offset, total) } func (h *Handler) GetOutbound(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "invalid outbound order id") return } orgID, ok := organizationID(c) if !ok { return } order, err := h.outboundSvc.GetByID(orgID, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { response.NotFound(c, "Outbound order not found") return } response.InternalError(c, err.Error()) return } response.Success(c, order) } func (h *Handler) CreateOutbound(c *gin.Context) { var req model.CreateOutboundRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } orgID, ok := organizationID(c) if !ok { return } operator := middleware.GetCurrentUsername(c) if operator == "" { operator = "系统" } order, err := h.outboundSvc.Create(orgID, &req, operator) if err != nil { if errors.Is(err, service.ErrMaterialNotFound) { response.BadRequest(c, "material not found") return } if errors.Is(err, service.ErrMaterialObsolete) { response.BadRequest(c, "material is obsolete") return } if errors.Is(err, service.ErrInsufficientStock) { response.BadRequest(c, userFacingServiceErrorMessage(err)) return } if errors.Is(err, service.ErrInventoryScopeUnavailable) { response.Error(c, 409, err.Error()) return } response.InternalError(c, err.Error()) return } // 事务已提交后再广播,且带公司归属,仅同公司订阅者会收到。 GetHub().BroadcastStockUpdate(orgID, "outbound", order.ID, order) response.Created(c, "Outbound order created successfully", order) } func (h *Handler) DeleteOutbound(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "invalid outbound order id") return } orgID, ok := organizationID(c) if !ok { return } err = h.outboundSvc.Delete(orgID, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { response.NotFound(c, "Outbound order not found") return } if errors.Is(err, service.ErrInventoryScopeUnavailable) { response.Error(c, 409, err.Error()) return } response.InternalError(c, err.Error()) return } response.SuccessWithMessage(c, "Outbound order deleted successfully", nil) } func (h *Handler) UpdateOutbound(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "invalid outbound order id") return } var req model.UpdateOutboundRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } orgID, ok := organizationID(c) if !ok { return } operator := middleware.GetCurrentUsername(c) if operator == "" { operator = "系统" } order, err := h.outboundSvc.Update(orgID, id, &req, operator) if err != nil { if errors.Is(err, sql.ErrNoRows) { response.NotFound(c, "Outbound order not found") return } if errors.Is(err, service.ErrMaterialNotFound) { response.BadRequest(c, "material not found") return } if errors.Is(err, service.ErrMaterialObsolete) { response.BadRequest(c, "material is obsolete") return } if errors.Is(err, service.ErrInsufficientStock) { response.BadRequest(c, userFacingServiceErrorMessage(err)) return } if errors.Is(err, service.ErrInventoryScopeUnavailable) { response.Error(c, 409, err.Error()) return } response.InternalError(c, err.Error()) return } GetHub().BroadcastStockUpdate(orgID, "outbound", order.ID, order) response.Success(c, order) } func (h *Handler) GetOutboundByMaterial(c *gin.Context) { materialID, err := strconv.ParseInt(c.Param("materialId"), 10, 64) if err != nil { response.BadRequest(c, "invalid material id") return } orgID, ok := organizationID(c) if !ok { return } records, err := h.outboundSvc.GetByMaterialID(orgID, materialID) if err != nil { response.InternalError(c, err.Error()) return } if records == nil { records = []model.OutboundRecord{} } response.Success(c, records) } // Stock handlers func (h *Handler) ListStock(c *gin.Context) { limit := 20 offset := 0 if l := c.Query("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed if limit > 100 { limit = 100 } } } if o := c.Query("offset"); o != "" { if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { offset = parsed } } search := c.Query("search") // 大类编码筛选,前端下拉选中的就是 material_categories 里的大类 code category := c.Query("category") orgID, ok := organizationID(c) if !ok { return } stocks, total, err := h.stockSvc.GetAllStock(orgID, search, category, limit, offset) if err != nil { response.InternalError(c, err.Error()) return } if stocks == nil { stocks = []model.Stock{} } response.PaginatedSuccess(c, stocks, limit, offset, total) } func (h *Handler) GetStock(c *gin.Context) { materialID, err := strconv.ParseInt(c.Param("materialId"), 10, 64) if err != nil { response.BadRequest(c, "invalid material id") return } orgID, ok := organizationID(c) if !ok { return } stock, err := h.stockSvc.GetByMaterialID(orgID, materialID) if err != nil { if errors.Is(err, sql.ErrNoRows) { response.NotFound(c, "Material not found") return } response.InternalError(c, err.Error()) return } response.Success(c, stock) } // GetInventoryBalanceDetails 返回当前公司某物料的库存维度明细 func (h *Handler) GetInventoryBalanceDetails(c *gin.Context) { materialID, err := strconv.ParseInt(c.Param("materialId"), 10, 64) if err != nil { response.BadRequest(c, "invalid material id") return } orgID, ok := organizationID(c) if !ok { return } exists, err := h.stockSvc.MaterialExists(orgID, materialID) if err != nil { response.InternalError(c, err.Error()) return } if !exists { response.NotFound(c, "Material not found") return } details, err := h.stockSvc.GetInventoryBalanceDetails(orgID, materialID) if err != nil { response.InternalError(c, err.Error()) return } if details == nil { details = []model.InventoryBalanceDetail{} } response.Success(c, details) } func (h *Handler) GetWarningStock(c *gin.Context) { limit := 20 offset := 0 if l := c.Query("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed if limit > 100 { limit = 100 } } } if o := c.Query("offset"); o != "" { if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { offset = parsed } } orgID, ok := organizationID(c) if !ok { return } stocks, total, err := h.stockSvc.GetWarningStock(orgID, limit, offset) if err != nil { response.InternalError(c, err.Error()) return } if stocks == nil { stocks = []model.Stock{} } response.PaginatedSuccess(c, stocks, limit, offset, total) } func (h *Handler) UpdateWarningStock(c *gin.Context) { materialID, err := strconv.ParseInt(c.Param("materialId"), 10, 64) if err != nil { response.BadRequest(c, "invalid material id") return } var req struct { WarningStock decimal.Decimal `json:"warning_stock"` // Enabled 用指针接收:不传表示沿用旧行为(启用),只有显式传 false 才是关闭 // 该物料的预警。 Enabled *bool `json:"enabled"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } if req.WarningStock.IsNegative() { response.BadRequest(c, "warning_stock must be greater than or equal to 0") return } enabled := true if req.Enabled != nil { enabled = *req.Enabled } orgID, ok := organizationID(c) if !ok { return } // 先校验物料归属:预警规则的外键指向全局 materials(id),不校验的话写别家公司的 // material_id 会静默成功。跨公司一律 404,与库存详情、流水保持一致。 exists, err := h.stockSvc.MaterialExists(orgID, materialID) if err != nil { response.InternalError(c, err.Error()) return } if !exists { response.NotFound(c, "Material not found") return } // 改变库存域的写操作必须落在当前公司启用的仓库、库位上;缺失或停用时回 409 并给出 // 具体配置提示,而不是写进默认公司或静默成功。 scope, err := service.ResolveInventoryScope(orgID, true) if err != nil { if errors.Is(err, service.ErrInventoryScopeUnavailable) { response.Error(c, 409, err.Error()) return } response.InternalError(c, err.Error()) return } if err := h.stockSvc.UpdateWarningStock(scope, materialID, req.WarningStock, enabled); err != nil { response.InternalError(c, err.Error()) return } response.SuccessWithMessage(c, "Stock warning updated successfully", nil) } func (h *Handler) GetStockLogs(c *gin.Context) { materialID, err := strconv.ParseInt(c.Param("materialId"), 10, 64) if err != nil { response.BadRequest(c, "invalid material id") return } orgID, ok := organizationID(c) if !ok { return } exists, err := h.stockSvc.MaterialExists(orgID, materialID) if err != nil { response.InternalError(c, err.Error()) return } if !exists { response.NotFound(c, "Material not found") return } limit := 20 offset := 0 if l := c.Query("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed if limit > 100 { limit = 100 } } } if o := c.Query("offset"); o != "" { if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { offset = parsed } } logs, total, err := h.stockSvc.GetLogsByMaterialID(orgID, materialID, limit, offset) if err != nil { response.InternalError(c, err.Error()) return } if logs == nil { logs = []model.StockLogResponse{} } response.PaginatedSuccess(c, logs, limit, offset, total) } func (h *Handler) GetAllStockLogs(c *gin.Context) { limit := 20 offset := 0 if l := c.Query("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed if limit > 100 { limit = 100 } } } if o := c.Query("offset"); o != "" { if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { offset = parsed } } orgID, ok := organizationID(c) if !ok { return } logs, total, err := h.stockSvc.GetAllLogs(orgID, limit, offset) if err != nil { response.InternalError(c, err.Error()) return } if logs == nil { logs = []model.StockLogResponse{} } response.PaginatedSuccess(c, logs, limit, offset, total) } // Material Log handlers func (h *Handler) ListLogs(c *gin.Context) { orgID, ok := organizationID(c) if !ok { return } operationType := c.Query("operation_type") materialId := c.Query("material_id") startDate := c.Query("start_date") endDate := c.Query("end_date") limit := 20 offset := 0 if l := c.Query("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed if limit > 100 { limit = 100 } } } if o := c.Query("offset"); o != "" { if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { offset = parsed } } logs, total, err := h.logSvc.List(orgID, operationType, materialId, startDate, endDate, limit, offset) if err != nil { response.InternalError(c, err.Error()) return } if logs == nil { logs = []model.MaterialLog{} } response.PaginatedSuccess(c, logs, limit, offset, total) } func (h *Handler) GetLogsByMaterial(c *gin.Context) { materialID, err := strconv.ParseInt(c.Param("materialId"), 10, 64) if err != nil { response.BadRequest(c, "invalid material id") return } orgID, ok := organizationID(c) if !ok { return } logs, err := h.logSvc.GetByMaterialID(orgID, materialID) if err != nil { response.InternalError(c, err.Error()) return } if logs == nil { logs = []model.MaterialLog{} } response.Success(c, logs) } // Auth handlers func (h *Handler) Login(c *gin.Context) { ip := c.ClientIP() // Check if IP is rate limited before processing if middleware.IsRateLimited(ip) { response.Error(c, 429, "too many login attempts, please try again later") return } var req model.LoginRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } result, err := h.authSvc.Login(req.Username, req.Password) if err != nil { // Record failed attempt middleware.RecordFailedAttempt(ip) response.Unauthorized(c, "invalid username or password") return } // Reset attempts on successful login middleware.ResetAttempts(ip) response.Success(c, result) } func (h *Handler) Logout(c *gin.Context) { // For simple token auth, logout is handled client-side by removing the token response.SuccessWithMessage(c, "logout successful", nil) } func (h *Handler) GetCurrentUser(c *gin.Context) { userID := middleware.GetCurrentUserID(c) user, err := h.authSvc.GetUserByID(userID) if err != nil { response.NotFound(c, "user not found") return } response.Success(c, user) } // CreateWebSocketTicket 为当前登录用户与已校验的公司签发一张短期一次性票据, // 供 /ws 握手使用。JWT 与公司头仍走正常请求头,绝不放进 WebSocket 的 URL。 func (h *Handler) CreateWebSocketTicket(c *gin.Context) { userID := middleware.GetCurrentUserID(c) organizationID := middleware.GetCurrentOrganizationID(c) ticket, err := service.GetWebSocketTicketService().Issue(userID, organizationID) if err != nil { response.InternalError(c, "生成 WebSocket 票据失败") return } response.Success(c, gin.H{ "ticket": ticket.Ticket, "expires_at": ticket.ExpiresAt.Unix(), }) } // User management handlers (Admin only) func (h *Handler) ListUsers(c *gin.Context) { limit := 20 offset := 0 if l := c.Query("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed if limit > 100 { limit = 100 } } } if o := c.Query("offset"); o != "" { if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { offset = parsed } } search := c.Query("search") users, total, err := h.authSvc.ListUsers(search, limit, offset) if err != nil { response.InternalError(c, err.Error()) return } if users == nil { users = []model.User{} } response.PaginatedSuccess(c, users, limit, offset, total) } func (h *Handler) CreateUser(c *gin.Context) { var req struct { model.UserOrganizationInput Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required"` RoleIDs []int64 `json:"role_ids" binding:"required,min=1"` Description string `json:"description"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } req.ActorUserID = middleware.GetCurrentUserID(c) user, err := h.authSvc.CreateUser(req.Username, req.Password, req.Description, req.RoleIDs, req.UserOrganizationInput) if err != nil { if message := roleErrorText(err); message != "" { response.BadRequest(c, message) return } response.InternalError(c, err.Error()) return } response.Created(c, "user created successfully", user) } func (h *Handler) UpdateUser(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "invalid user id") return } var req struct { model.UserOrganizationInput Password string `json:"password"` RoleIDs []int64 `json:"role_ids" binding:"required,min=1"` Description string `json:"description"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } req.ActorUserID = middleware.GetCurrentUserID(c) err = h.authSvc.UpdateUser(id, req.Description, req.RoleIDs, req.Password, req.UserOrganizationInput) if err != nil { if message := roleErrorText(err); message != "" { response.BadRequest(c, message) return } response.InternalError(c, err.Error()) return } response.SuccessWithMessage(c, "user updated successfully", nil) } func (h *Handler) DeleteUser(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "invalid user id") return } // Prevent deleting yourself currentUserID := middleware.GetCurrentUserID(c) if currentUserID == id { response.BadRequest(c, "cannot delete yourself") return } if err := h.authSvc.DeleteUser(id); err != nil { if message := roleErrorText(err); message != "" { response.BadRequest(c, message) return } response.InternalError(c, err.Error()) return } response.SuccessWithMessage(c, "user deleted successfully", nil) } // roleErrorText 把角色相关的仓储层错误翻成面向用户的中文,返回空字符串表示这个错误 // 与角色无关。翻译放在 handler 而非仓储层:仓储层的错误常量是给开发者看的标识,不该 // 承担界面文案。前端的 utils/errorText.js 只放行带中文的消息,英文会被换成笼统的 // "请求参数有误",用户看不出问题出在角色上。 func roleErrorText(err error) string { switch { case errors.Is(err, repository.ErrInvalidOrganizations): return err.Error() case errors.Is(err, repository.ErrInvalidRoles): return "所选角色无效或已被停用,请重新选择" case errors.Is(err, repository.ErrLastSuperAdmin): return "不能移除最后一个超级管理员" default: return "" } } // Application handlers func (h *Handler) CreateApplication(c *gin.Context) { userID := middleware.GetCurrentUserID(c) var req model.CreateApplicationRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } orgID, ok := organizationID(c) if !ok { return } app, err := h.appSvc.Create(orgID, userID, &req) if err != nil { if errors.Is(err, service.ErrMaterialNotFound) || errors.Is(err, service.ErrMaterialObsolete) { response.BadRequest(c, err.Error()) return } response.InternalError(c, err.Error()) return } response.Created(c, "application created successfully", app) } func (h *Handler) ListMyApplications(c *gin.Context) { userID := middleware.GetCurrentUserID(c) status := c.Query("status") limit := 20 offset := 0 if l := c.Query("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed if limit > 100 { limit = 100 } } } if o := c.Query("offset"); o != "" { if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { offset = parsed } } orgID, ok := organizationID(c) if !ok { return } apps, total, err := h.appSvc.ListByUser(orgID, userID, status, limit, offset) if err != nil { response.InternalError(c, err.Error()) return } response.PaginatedSuccess(c, apps, limit, offset, int64(total)) } func (h *Handler) ListApplications(c *gin.Context) { if !middleware.IsSuperAdmin(c) { response.Unauthorized(c, "admin only") return } orgID, ok := organizationID(c) if !ok { return } status := c.Query("status") limit := 20 offset := 0 if l := c.Query("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed if limit > 100 { limit = 100 } } } if o := c.Query("offset"); o != "" { if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { offset = parsed } } apps, total, err := h.appSvc.ListAll(orgID, status, limit, offset) if err != nil { response.InternalError(c, err.Error()) return } response.PaginatedSuccess(c, apps, limit, offset, int64(total)) } func (h *Handler) CancelApplication(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "invalid application id") return } orgID, ok := organizationID(c) if !ok { return } userID := middleware.GetCurrentUserID(c) if err := h.appSvc.Cancel(orgID, id, userID); err != nil { if errors.Is(err, service.ErrApplicationNotFound) { response.NotFound(c, "application not found") return } if errors.Is(err, service.ErrCannotCancelOthers) || errors.Is(err, service.ErrCannotCancelApplication) { response.BadRequest(c, err.Error()) return } response.InternalError(c, err.Error()) return } response.SuccessWithMessage(c, "application cancelled successfully", nil) } func (h *Handler) ApproveApplication(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "invalid application id") return } if !middleware.IsSuperAdmin(c) { response.Unauthorized(c, "admin only") return } var req model.ApproveApplicationRequest c.ShouldBindJSON(&req) // optional orgID, ok := organizationID(c) if !ok { return } approverID := middleware.GetCurrentUserID(c) if err := h.appSvc.Approve(orgID, id, approverID, req.AutoOutbound); err != nil { if err == service.ErrApplicationNotFound { response.NotFound(c, "application not found") return } if err == service.ErrApplicationNotPending { response.BadRequest(c, "application is not pending") return } if err == service.ErrInsufficientStock { response.BadRequest(c, userFacingServiceErrorMessage(err)) return } if errors.Is(err, service.ErrInventoryScopeUnavailable) { response.Error(c, 409, err.Error()) return } response.InternalError(c, err.Error()) return } response.SuccessWithMessage(c, "application approved successfully", nil) } func (h *Handler) RejectApplication(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "invalid application id") return } if !middleware.IsSuperAdmin(c) { response.Unauthorized(c, "admin only") return } orgID, ok := organizationID(c) if !ok { return } var req model.RejectApplicationRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "rejection reason is required") return } if err := h.appSvc.Reject(orgID, id, req.Reason); err != nil { if errors.Is(err, service.ErrApplicationNotFound) { response.NotFound(c, "application not found") return } if errors.Is(err, service.ErrApplicationNotPending) { response.BadRequest(c, "application is not pending") return } response.InternalError(c, err.Error()) return } response.SuccessWithMessage(c, "application rejected successfully", nil) }