material_manage_system/internal/handler/api.go
2026-10-12 15:37:54 +08:00

1642 lines
42 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"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
materialImportSvc *service.MaterialImportService
}
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(),
materialImportSvc: service.NewMaterialImportService(),
}
}
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)
}
// 物料批量导入单独挂前缀。这不是 gin 的限制——实测 v1.9.1 允许静态段与
// :id 同层共存,把它放进上面的 materials 组并不会冲突;独立成前缀是因为
// 「导入」是含模板下载与上传入库的一条完整链路,两个端点归在一处,
// 也避免 /materials/import 被读成某个物料的 id。
materialImport := api.Group("/material-import")
materialImport.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware())
{
materialImport.GET("/template", h.DownloadMaterialImportTemplate)
materialImport.POST("", h.ImportMaterials)
}
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) || errors.Is(err, service.ErrMaterialNameTooLong) || errors.Is(err, service.ErrMaterialSpecTooLong) {
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) || errors.Is(err, service.ErrMaterialNameTooLong) || errors.Is(err, service.ErrMaterialSpecTooLong) {
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
}
if !h.requireCompanyMaterial(c, orgID, materialID) {
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
}
if !h.requireCompanyMaterial(c, orgID, materialID) {
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
}
if !h.requireCompanyMaterial(c, orgID, materialID) {
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)
}
// History endpoints must distinguish an empty history of an owned material
// from a material ID outside the selected company, without revealing its owner.
func (h *Handler) requireCompanyMaterial(c *gin.Context, organizationID, materialID int64) bool {
exists, err := h.materialSvc.BelongsToCompany(organizationID, materialID)
if err != nil {
response.InternalError(c, err.Error())
return false
}
if !exists {
response.NotFound(c, "material not found")
return false
}
return true
}
func (h *Handler) Logout(c *gin.Context) {
if err := h.authSvc.RevokeSession(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")); err != nil {
response.InternalError(c, "session revocation failed")
return
}
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, strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer "))
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)
}