feat: 物料导入与模板下载接口
- 新增 response.ErrorWithData:校验失败要把逐行明细一起带回,不能只给一句话 - 新增 GET /api/material-import/template 与 POST /api/material-import - 路由挂在独立前缀 /material-import:materials 组已有 GET /:id,gin 的路由树 不允许同一段既有静态前缀又有通配参数 - 权限沿用物料新增的口径:登录 + 公司头校验,不额外要求 super_admin - 模板随代码生成不落盘,下载到的永远与当前校验规则一致
This commit is contained in:
parent
f34803e1a2
commit
7b926ff60a
|
|
@ -16,17 +16,18 @@ import (
|
|||
)
|
||||
|
||||
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
|
||||
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 {
|
||||
|
|
@ -38,17 +39,18 @@ func userFacingServiceErrorMessage(err error) string {
|
|||
|
||||
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(),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +95,15 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
|
|||
materials.POST("/:id/restore", h.RestoreMaterial)
|
||||
}
|
||||
|
||||
// 物料批量导入单独挂前缀:materials 组已有 GET /:id,
|
||||
// gin 的路由树不允许同段既有静态前缀又有通配参数。
|
||||
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())
|
||||
{
|
||||
|
|
|
|||
73
internal/handler/material_import.go
Normal file
73
internal/handler/material_import.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/wmms/wmms/internal/middleware"
|
||||
"github.com/wmms/wmms/internal/service"
|
||||
"github.com/wmms/wmms/pkg/response"
|
||||
)
|
||||
|
||||
// DownloadMaterialImportTemplate 返回物料导入模板文件。模板随代码生成,
|
||||
// 不落盘,保证下载到的永远和当前校验规则一致。
|
||||
func (h *Handler) DownloadMaterialImportTemplate(c *gin.Context) {
|
||||
data, err := service.BuildMaterialImportTemplate()
|
||||
if err != nil {
|
||||
response.InternalError(c, "模板生成失败")
|
||||
return
|
||||
}
|
||||
fileName := url.PathEscape("物料导入模板.xlsx")
|
||||
c.Header("Content-Disposition", `attachment; filename="material-import-template.xlsx"; filename*=UTF-8''`+fileName)
|
||||
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data)
|
||||
}
|
||||
|
||||
// ImportMaterials 接收上传的 xlsx,校验通过即整批入库。
|
||||
// 校验失败返回 400 且 data 里带逐行错误明细。
|
||||
func (h *Handler) ImportMaterials(c *gin.Context) {
|
||||
orgID, ok := organizationID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "请选择要导入的 xlsx 文件")
|
||||
return
|
||||
}
|
||||
if file.Size > service.MaterialImportMaxBytes {
|
||||
response.BadRequest(c, "文件超过 5MB 上限")
|
||||
return
|
||||
}
|
||||
|
||||
source, err := file.Open()
|
||||
if err != nil {
|
||||
response.BadRequest(c, "文件读取失败")
|
||||
return
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
operator := middleware.GetCurrentUsername(c)
|
||||
if operator == "" {
|
||||
operator = "系统"
|
||||
}
|
||||
|
||||
result, err := h.materialImportSvc.Import(orgID, operator, file.Filename, source)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrImportTemplateInvalid) || errors.Is(err, service.ErrImportTooManyRows) {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
if result.FailedRows > 0 {
|
||||
response.ErrorWithData(c, http.StatusBadRequest,
|
||||
"有 "+strconv.Itoa(result.FailedRows)+" 行校验未通过,本次未导入任何数据", result)
|
||||
return
|
||||
}
|
||||
response.Created(c, "成功导入 "+strconv.Itoa(result.SuccessRows)+" 条物料", result)
|
||||
}
|
||||
29
internal/handler/material_import_test.go
Normal file
29
internal/handler/material_import_test.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/wmms/wmms/pkg/response"
|
||||
)
|
||||
|
||||
func TestErrorWithDataKeepsPayload(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
|
||||
response.ErrorWithData(c, http.StatusBadRequest, "有 1 行校验未通过", map[string]interface{}{
|
||||
"total_rows": 1, "failed_rows": 1,
|
||||
})
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", recorder.Code)
|
||||
}
|
||||
body := recorder.Body.String()
|
||||
if !strings.Contains(body, "有 1 行校验未通过") || !strings.Contains(body, `"failed_rows":1`) {
|
||||
t.Fatalf("body = %s", body)
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ func TestPhaseOneAndLegacyRoutesAreRegistered(t *testing.T) {
|
|||
"POST /api/materials/:id/obsolete", "POST /api/materials/:id/restore",
|
||||
"PUT /api/stock/:materialId/warning", "GET /api/stock/:materialId/details",
|
||||
"POST /api/applications", "GET /api/logs",
|
||||
"GET /api/material-import/template", "POST /api/material-import",
|
||||
}
|
||||
for _, route := range want {
|
||||
if !registered[route] {
|
||||
|
|
|
|||
|
|
@ -65,6 +65,16 @@ func Error(c *gin.Context, httpStatus int, message string) {
|
|||
})
|
||||
}
|
||||
|
||||
// ErrorWithData 返回带业务数据的错误响应。批量导入校验失败时需要把逐行错误
|
||||
// 一起带回去,不能只给一句话。
|
||||
func ErrorWithData(c *gin.Context, httpStatus int, message string, data interface{}) {
|
||||
c.JSON(httpStatus, Response{
|
||||
Code: httpStatus,
|
||||
Message: message,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func BadRequest(c *gin.Context, message string) {
|
||||
Error(c, 400, message)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user